mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
chore(skills): harden openclaw skill metadata (#191)
* chore(skills): harden openclaw skill metadata * fix(openclaw-audit-watchdog): add dated release note heading * chore(skills): normalize openclaw naming * fix(soul-guardian): preserve legacy launchd state dir * fix(soul-guardian): clean up legacy launchd labels
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Claw Release skill will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.2] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes that make the required maintainer credentials, runtime, and git/GitHub side effects explicit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared `bash` alongside the existing `git`, `jq`, and `gh` runtime requirements in skill metadata.
|
||||
- Replaced the documented destructive rollback example with a softer rollback flow that preserves release changes for review.
|
||||
|
||||
### Security
|
||||
|
||||
- Clarified that this internal skill mutates git state, pushes to remotes, and publishes GitHub Releases, so it should only be run from a trusted checkout by maintainers.
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: claw-release
|
||||
version: 0.0.1
|
||||
version: 0.0.2
|
||||
description: Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🚀","category":"utility","internal":true}}
|
||||
clawdis:
|
||||
emoji: "🚀"
|
||||
requires:
|
||||
bins: [git, jq, gh]
|
||||
bins: [bash, git, jq, gh]
|
||||
---
|
||||
|
||||
# Claw Release
|
||||
@@ -18,6 +18,14 @@ Internal tool for releasing skills and managing the ClawSec catalog.
|
||||
|
||||
---
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Internal maintainer workflow only.
|
||||
- Required runtime: `bash`, `git`, `jq`, `gh`
|
||||
- Required credentials: authenticated GitHub CLI with permission to create releases
|
||||
- Side effects: creates commits, tags, pushes to remote, and publishes GitHub Releases
|
||||
- Trust model: run only from a trusted checkout with a clean working tree and maintainer approval
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Release Type | Command | Tag Format |
|
||||
@@ -93,9 +101,12 @@ Verify at:
|
||||
If you need to undo before pushing:
|
||||
|
||||
```bash
|
||||
git reset --hard HEAD~1 && git tag -d <skill-name>-v<version>
|
||||
git tag -d <skill-name>-v<version>
|
||||
git reset --soft HEAD~1
|
||||
```
|
||||
|
||||
`git reset --soft` preserves the release changes in your working tree so you can inspect or amend them without discarding data.
|
||||
|
||||
---
|
||||
|
||||
## Pre-release Versions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claw-release",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
"sbom": {
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "required": true, "description": "Release workflow guide" }
|
||||
{ "path": "SKILL.md", "required": true, "description": "Release workflow guide" },
|
||||
{ "path": "CHANGELOG.md", "required": true, "description": "Version history and release notes" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -17,7 +18,25 @@
|
||||
"emoji": "🚀",
|
||||
"category": "utility",
|
||||
"internal": true,
|
||||
"requires": { "bins": ["git", "jq", "gh"] },
|
||||
"requires": { "bins": ["bash", "git", "jq", "gh"] },
|
||||
"runtime": {
|
||||
"required_env": [
|
||||
"GH_TOKEN or existing gh auth"
|
||||
],
|
||||
"optional_bins": [
|
||||
"git-lfs"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No recurring automation; this is a maintainer-invoked release workflow.",
|
||||
"network_egress": "Pushes git commits/tags and creates GitHub Releases when the maintainer runs the documented release flow."
|
||||
},
|
||||
"operator_review": [
|
||||
"Internal maintainer tool only; it mutates git state, tags, and GitHub release metadata.",
|
||||
"Run it only from a trusted checkout with maintainer credentials and a clean working tree.",
|
||||
"Prefer non-destructive rollback steps; avoid rewriting history unless you explicitly intend to."
|
||||
],
|
||||
"triggers": [
|
||||
"release skill",
|
||||
"create release",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec ClawHub Checker will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.2] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Runtime and operator-review metadata describing the suite dependency, ClawHub lookups, and in-place integration behavior.
|
||||
- Preflight disclosure in `scripts/setup_reputation_hook.mjs` before the installed suite is modified.
|
||||
- Regression coverage for setup disclosure in `test/setup_reputation_hook.test.mjs`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared `node` and `openclaw` as required runtimes alongside `clawhub` because the integration flow depends on all three.
|
||||
- Documented that setup rewrites installed `clawsec-suite` files rather than operating on a detached copy.
|
||||
|
||||
### Security
|
||||
|
||||
- Made the string-based `handler.ts` rewrite and the remote ClawHub reputation-query behavior explicit so operators can review the mutation and network trust model before enabling it.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
A ClawSec suite skill that enhances the guarded skill installer with ClawHub reputation checks and VirusTotal Code Insight integration.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `node`, `clawhub`, `openclaw`
|
||||
- Dependency: installed `clawsec-suite`
|
||||
- Setup mutates the installed suite in place by copying helper scripts and rewriting the advisory guardian hook handler
|
||||
- Reputation checks contact ClawHub and can surface heuristic false positives; risky installs still require explicit user confirmation
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds a second layer of security to skill installation by:
|
||||
@@ -37,6 +44,8 @@ node scripts/setup_reputation_hook.mjs
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
The setup script prints a preflight review before it mutates the installed suite files.
|
||||
|
||||
Setup installs these scripts into `clawsec-suite/scripts`:
|
||||
- `enhanced_guarded_install.mjs`
|
||||
- `guarded_skill_install_wrapper.mjs` (drop-in wrapper)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.1
|
||||
version: 0.0.2
|
||||
description: ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🛡️"
|
||||
requires:
|
||||
bins: [clawhub, curl, jq]
|
||||
bins: [node, clawhub, openclaw]
|
||||
depends_on: [clawsec-suite]
|
||||
---
|
||||
|
||||
@@ -14,6 +14,14 @@ clawdis:
|
||||
|
||||
Enhances the ClawSec suite's guarded skill installer with ClawHub reputation checks. Adds a second layer of security by checking VirusTotal Code Insight scores and other reputation signals before allowing skill installation.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `node`, `clawhub`, `openclaw`
|
||||
- Depends on: installed `clawsec-suite`
|
||||
- Side effects: `setup_reputation_hook.mjs` copies files into the installed suite and rewrites `hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- Network behavior: reputation checks query ClawHub and may trigger remote metadata lookups during `inspect`/declined `install` flows
|
||||
- Trust model: reputation scores are heuristic, not authoritative; keep the double-confirmation flow enabled
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Wraps `clawhub install`** - Intercepts skill installation requests
|
||||
@@ -40,10 +48,14 @@ node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mj
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
The setup script prints a preflight review before it mutates the installed suite files.
|
||||
|
||||
After setup, the checker adds `enhanced_guarded_install.mjs` and
|
||||
`guarded_skill_install_wrapper.mjs` under `clawsec-suite/scripts` and updates the advisory
|
||||
guardian hook. The original `guarded_skill_install.mjs` is not replaced.
|
||||
|
||||
Review the printed preflight summary before running setup. The script intentionally modifies the installed suite in place rather than operating on a temporary copy.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Enhanced Guarded Installer
|
||||
|
||||
@@ -4,6 +4,19 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
function printPreflightSummary({ suiteDir, checkerDir, hookLibDir }) {
|
||||
const lines = [
|
||||
"Preflight review:",
|
||||
`- This setup will rewrite installed clawsec-suite integration files under ${suiteDir}.`,
|
||||
`- It copies reputation helpers from ${checkerDir} and applies a string-based patch to handler.ts in ${hookLibDir}.`,
|
||||
"- Required runtime for the integrated flow: node, clawhub, openclaw.",
|
||||
"- After setup, reputation checks query ClawHub and may trigger remote metadata lookups; risky installs remain approval-gated with --confirm-reputation.",
|
||||
"- Restart OpenClaw gateway for hook changes to take effect.",
|
||||
];
|
||||
|
||||
console.log(lines.join("\n") + "\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Setting up ClawHub reputation checker integration...");
|
||||
|
||||
@@ -12,6 +25,8 @@ async function main() {
|
||||
const checkerDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-clawhub-checker");
|
||||
const hookLibDir = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "lib");
|
||||
const suiteScriptsDir = path.join(suiteDir, "scripts");
|
||||
|
||||
printPreflightSummary({ suiteDir, checkerDir, hookLibDir });
|
||||
|
||||
try {
|
||||
// Check if clawsec-suite is installed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -48,10 +48,20 @@
|
||||
"required": false,
|
||||
"description": "Additional documentation and development guide"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "test/reputation_check.test.mjs",
|
||||
"required": false,
|
||||
"description": "Test suite for reputation checking functionality"
|
||||
},
|
||||
{
|
||||
"path": "test/setup_reputation_hook.test.mjs",
|
||||
"required": false,
|
||||
"description": "Regression coverage for setup preflight disclosure"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -77,8 +87,24 @@
|
||||
"emoji": "🛡️",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": ["clawhub", "curl", "jq"]
|
||||
"bins": ["node", "clawhub", "openclaw"]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [],
|
||||
"optional_env": [
|
||||
"CLAWHUB_REPUTATION_THRESHOLD"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "The setup script rewrites installed clawsec-suite integration files and augments the advisory guardian hook until removed or replaced.",
|
||||
"network_egress": "Reputation checks query ClawHub metadata and may trigger ClawHub install/inspect flows that contact remote services."
|
||||
},
|
||||
"operator_review": [
|
||||
"Requires an installed clawsec-suite checkout because setup rewrites handler.ts and copies helper scripts into the suite.",
|
||||
"Reputation results are heuristic and can produce false positives; installation still requires explicit user confirmation for risky skills.",
|
||||
"Review the modified suite files and restart OpenClaw gateway after setup so the hook changes load intentionally."
|
||||
],
|
||||
"triggers": [
|
||||
"clawhub reputation",
|
||||
"skill reputation check",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTempDir, pass, fail, report, exitWithResults } from "../../clawsec-suite/test/lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const NODE_BIN = process.execPath;
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "setup_reputation_hook.mjs");
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
|
||||
async function runScript(env) {
|
||||
return await new Promise((resolve) => {
|
||||
const proc = spawn(NODE_BIN, [SCRIPT_PATH], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stageInstalledSkill(tempHome, skillName) {
|
||||
const sourceDir = path.join(REPO_ROOT, "skills", skillName);
|
||||
const destDir = path.join(tempHome, ".openclaw", "skills", skillName);
|
||||
await fs.mkdir(path.dirname(destDir), { recursive: true });
|
||||
await fs.cp(sourceDir, destDir, { recursive: true });
|
||||
return destDir;
|
||||
}
|
||||
|
||||
async function testPreflightSummaryAndMutation() {
|
||||
const testName = "setup_reputation_hook: prints preflight review before mutating installed suite files";
|
||||
const tmp = await createTempDir();
|
||||
const homeDir = path.join(tmp.path, "home");
|
||||
|
||||
try {
|
||||
await stageInstalledSkill(homeDir, "clawsec-suite");
|
||||
await stageInstalledSkill(homeDir, "clawsec-clawhub-checker");
|
||||
|
||||
const result = await runScript({
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `script failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperPath = path.join(
|
||||
homeDir,
|
||||
".openclaw",
|
||||
"skills",
|
||||
"clawsec-suite",
|
||||
"scripts",
|
||||
"guarded_skill_install_wrapper.mjs",
|
||||
);
|
||||
const reputationModulePath = path.join(
|
||||
homeDir,
|
||||
".openclaw",
|
||||
"skills",
|
||||
"clawsec-suite",
|
||||
"hooks",
|
||||
"clawsec-advisory-guardian",
|
||||
"lib",
|
||||
"reputation.mjs",
|
||||
);
|
||||
|
||||
await fs.access(wrapperPath);
|
||||
await fs.access(reputationModulePath);
|
||||
|
||||
if (
|
||||
result.stdout.includes("Preflight review:") &&
|
||||
result.stdout.includes("rewrite installed clawsec-suite integration files") &&
|
||||
result.stdout.includes("string-based patch to handler.ts") &&
|
||||
result.stdout.includes("Restart OpenClaw gateway for hook changes to take effect")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `missing preflight detail: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
await testPreflightSummaryAndMutation();
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
console.error("Test runner failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,6 +5,23 @@ All notable changes to the ClawSec Feed skill will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.6] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes in the skill docs that distinguish standalone feed installation from `clawsec-suite` automation responsibilities.
|
||||
- Metadata describing required standalone install tooling and operator review expectations.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clarified that the standalone feed package does not itself create persistence, hooks, or cron jobs.
|
||||
- Declared checksum/extraction tooling used by the documented install flow (`bash`, `shasum`, `unzip`) in skill metadata.
|
||||
- Normalized product naming in the skill docs to use OpenClaw terminology.
|
||||
|
||||
### Security
|
||||
|
||||
- Made release-provenance and checksum verification expectations explicit for standalone installations on production hosts.
|
||||
|
||||
## [0.0.5] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime for standalone installation: `bash`, `curl`, `jq`, `shasum`, `unzip`
|
||||
- This package is advisory data plus install/update guidance; it does not create local persistence by itself
|
||||
- Automated polling, installed-skill cross-referencing, and hook/cron behavior live in `clawsec-suite`
|
||||
- Verify release provenance and checksums before installing the standalone artifact on production hosts
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Advisories** - Get notified about malicious skills, vulnerabilities, and attack patterns
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.5
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
version: 0.0.6
|
||||
description: Security advisory feed package for OpenClaw-related threats and vulnerabilities. The upstream feed is updated daily; local automation is handled by clawsec-suite or the operator.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
clawdis:
|
||||
emoji: "📡"
|
||||
requires:
|
||||
bins: [curl, jq]
|
||||
bins: [bash, curl, jq, shasum, unzip]
|
||||
---
|
||||
|
||||
# ClawSec Feed 📡
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
This feed is automatically updated daily with CVEs related to OpenClaw, clawdbot, and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
This feed is automatically updated daily with CVEs related to OpenClaw and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime for standalone installation: `bash`, `curl`, `jq`, `shasum`, `unzip`
|
||||
- Side effects: standalone install only writes local skill files
|
||||
- Network behavior: downloads release metadata/artifacts and, if you choose to poll manually, fetches the advisory feed
|
||||
- Trust model: this package does not itself create cron jobs or submit data externally; automation is delegated to `clawsec-suite` or your own scheduler
|
||||
|
||||
**An open source project by [Prompt Security](https://prompt.security)**
|
||||
|
||||
@@ -52,6 +59,8 @@ Install clawsec-feed independently without the full suite.
|
||||
|
||||
Continue below for standalone installation instructions.
|
||||
|
||||
Standalone installation is a network download workflow. Verify the release source and the provided checksums before installing it on production hosts.
|
||||
|
||||
---
|
||||
|
||||
Installation Steps:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -39,10 +39,23 @@
|
||||
"feed_url": "https://api.github.com/repos/prompt-security/ClawSec/releases?skill=clawsec-feed",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"bash",
|
||||
"curl",
|
||||
"jq"
|
||||
"jq",
|
||||
"shasum",
|
||||
"unzip"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No local persistence or automation is created by the standalone feed package; recurring polling is handled by clawsec-suite or the operator.",
|
||||
"network_egress": "Standalone installation downloads release artifacts and optional feed updates from Prompt Security GitHub/website endpoints."
|
||||
},
|
||||
"operator_review": [
|
||||
"This package is primarily signed advisory data plus install instructions; it does not itself create cron jobs or send data outward.",
|
||||
"Verify release provenance and checksums before installing on production hosts.",
|
||||
"If you need automated polling or host-side enforcement, use clawsec-suite which owns that automation layer."
|
||||
],
|
||||
"triggers": [
|
||||
"security advisories",
|
||||
"check advisories",
|
||||
|
||||
@@ -5,6 +5,26 @@ All notable changes to the ClawSec Suite will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.6] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Runtime and operator-review metadata covering hook installation, optional cron persistence, guarded install flows, and feed URL overrides.
|
||||
- Preflight disclosure in `scripts/setup_advisory_hook.mjs` and `scripts/setup_advisory_cron.mjs`.
|
||||
- Regression coverage for setup disclosure behavior in `test/setup_disclosure.test.mjs`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared `node`, `npx`, `openclaw`, and `unzip` in the suite runtime metadata to match the documented setup and install flows.
|
||||
- Updated catalog messaging for `openclaw-audit-watchdog` to reflect DM delivery with optional email instead of implying email-only reporting.
|
||||
- Marked local advisory signature/checksum SBOM entries as optional until those companion artifacts are bundled in the repository.
|
||||
- Removed legacy pre-OpenClaw naming from the suite catalog compatibility metadata.
|
||||
|
||||
### Security
|
||||
|
||||
- Hook and cron setup now announce their persistence and approval boundaries before enabling host-side automation.
|
||||
- Clarified that the suite can recommend removal or block risky installs, but destructive actions remain approval-gated.
|
||||
|
||||
## [0.1.5] - 2026-04-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.1.5
|
||||
version: 0.1.6
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "📦"
|
||||
requires:
|
||||
bins: [curl, jq, shasum, openssl]
|
||||
bins: [node, npx, openclaw, curl, jq, shasum, openssl, unzip]
|
||||
---
|
||||
|
||||
# ClawSec Suite
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `node`, `npx`, `openclaw`, `curl`, `jq`, `shasum`, `openssl`, `unzip`
|
||||
- Side effects: setup scripts install an advisory hook under `~/.openclaw/hooks`, optionally create an unattended `openclaw cron` job, and use `npx clawhub@latest install` for guarded installs
|
||||
- Network behavior: fetches signed advisory feed artifacts and remote catalog metadata unless you pin local paths
|
||||
- Trust model: the suite can recommend removal or block risky installs, but removal/install overrides stay approval-gated
|
||||
|
||||
This means `clawsec-suite` can:
|
||||
- monitor the ClawSec advisory feed,
|
||||
- track which advisories are new since last check,
|
||||
@@ -146,6 +153,8 @@ SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_hook.mjs"
|
||||
```
|
||||
|
||||
The setup script prints a preflight review before it installs and enables the persistent hook.
|
||||
|
||||
Optional: create/update a periodic cron nudge (default every `6h`) that triggers a main-session advisory scan:
|
||||
|
||||
```bash
|
||||
@@ -153,6 +162,8 @@ SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
|
||||
```
|
||||
|
||||
The cron setup script prints a preflight review before it creates or updates the unattended job.
|
||||
|
||||
What this adds:
|
||||
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
||||
- compare advisory `affected` entries against installed skills,
|
||||
|
||||
@@ -92,8 +92,21 @@ function editJob(jobId) {
|
||||
]);
|
||||
}
|
||||
|
||||
function printPreflightSummary() {
|
||||
const lines = [
|
||||
"Preflight review:",
|
||||
"- This setup creates or updates an unattended openclaw cron job in the main session.",
|
||||
"- Required runtime: openclaw CLI, node.",
|
||||
`- Schedule: every ${JOB_EVERY}`,
|
||||
"- The system event triggers an advisory scan and must request explicit approval before any removal.",
|
||||
];
|
||||
|
||||
process.stdout.write(lines.join("\n") + "\n\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
requireOpenClawCli();
|
||||
printPreflightSummary();
|
||||
|
||||
const jobsOut = sh("openclaw", ["cron", "list", "--json"]);
|
||||
const jobsPayload = JSON.parse(jobsOut);
|
||||
|
||||
@@ -64,12 +64,26 @@ function installHookFiles() {
|
||||
fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function printPreflightSummary() {
|
||||
const lines = [
|
||||
"Preflight review:",
|
||||
`- This setup installs a persistent OpenClaw hook under ${TARGET_HOOK_DIR} and enables it globally.`,
|
||||
"- Required runtime: openclaw CLI, node.",
|
||||
"- The installed hook fetches signed advisory feed data and may recommend removal of risky skills, but destructive actions remain approval-gated.",
|
||||
`- Source hook files: ${SOURCE_HOOK_DIR}`,
|
||||
"- Restart your OpenClaw gateway process after setup so the hook loads intentionally.",
|
||||
];
|
||||
|
||||
process.stdout.write(lines.join("\n") + "\n\n");
|
||||
}
|
||||
|
||||
function enableHook() {
|
||||
sh("openclaw", ["hooks", "enable", HOOK_NAME]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertSourceHookExists();
|
||||
printPreflightSummary();
|
||||
requireOpenClawCli();
|
||||
installHookFiles();
|
||||
enableHook();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -47,18 +47,18 @@
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for advisory feed"
|
||||
"required": false,
|
||||
"description": "Detached Ed25519 signature for advisory feed when bundled with the local suite seed"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json",
|
||||
"required": true,
|
||||
"description": "SHA-256 checksum manifest for advisory artifacts"
|
||||
"required": false,
|
||||
"description": "SHA-256 checksum manifest for advisory artifacts when bundled with the local suite seed"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for checksum manifest"
|
||||
"required": false,
|
||||
"description": "Detached Ed25519 signature for checksum manifest when bundled with the local suite seed"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed-signing-public.pem",
|
||||
@@ -177,17 +177,15 @@
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"openclaw-audit-watchdog": {
|
||||
"description": "Automated daily audits with email reporting",
|
||||
"description": "Automated daily audits with DM delivery and optional email reporting",
|
||||
"default_install": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot"
|
||||
"moltbot"
|
||||
],
|
||||
"note": "Tailored for OpenClaw/MoltBot family"
|
||||
},
|
||||
@@ -197,7 +195,6 @@
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
@@ -208,7 +205,6 @@
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
@@ -219,12 +215,45 @@
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"node",
|
||||
"npx",
|
||||
"openclaw",
|
||||
"curl",
|
||||
"jq",
|
||||
"shasum",
|
||||
"openssl"
|
||||
"openssl",
|
||||
"unzip"
|
||||
]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [],
|
||||
"optional_env": [
|
||||
"CLAWSEC_FEED_URL",
|
||||
"CLAWSEC_FEED_SIG_URL",
|
||||
"CLAWSEC_FEED_CHECKSUMS_URL",
|
||||
"CLAWSEC_FEED_CHECKSUMS_SIG_URL",
|
||||
"CLAWSEC_LOCAL_FEED",
|
||||
"CLAWSEC_LOCAL_FEED_SIG",
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
|
||||
"CLAWSEC_FEED_PUBLIC_KEY",
|
||||
"CLAWSEC_ALLOW_UNSIGNED_FEED",
|
||||
"CLAWSEC_VERIFY_CHECKSUM_MANIFEST",
|
||||
"CLAWSEC_HOOK_INTERVAL_SECONDS",
|
||||
"CLAWSEC_ADVISORY_CRON_NAME",
|
||||
"CLAWSEC_ADVISORY_CRON_EVERY"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "Setup scripts install and enable an OpenClaw advisory hook, and can optionally create a recurring openclaw cron job.",
|
||||
"network_egress": "Fetches signed advisory feed artifacts and uses npx/clawhub for guarded skill install flows."
|
||||
},
|
||||
"operator_review": [
|
||||
"Review the advisory hook and optional cron setup before enabling them because they create persistent host-side automation.",
|
||||
"The suite may recommend removal of risky skills, but destructive actions remain approval-gated.",
|
||||
"Verify feed signing keys and any CLAWSEC_* URL overrides before relying on remote feed data."
|
||||
],
|
||||
"triggers": [
|
||||
"clawsec suite",
|
||||
"security suite",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTempDir, pass, fail, report, exitWithResults } from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const NODE_BIN = process.execPath;
|
||||
const SETUP_CRON_SCRIPT = path.resolve(__dirname, "..", "scripts", "setup_advisory_cron.mjs");
|
||||
const SETUP_HOOK_SCRIPT = path.resolve(__dirname, "..", "scripts", "setup_advisory_hook.mjs");
|
||||
|
||||
async function writeExecutable(filePath, content) {
|
||||
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function createOpenClawFixture() {
|
||||
const tmp = await createTempDir();
|
||||
const binDir = path.join(tmp.path, "bin");
|
||||
const capturePath = path.join(tmp.path, "openclaw-calls.json");
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeExecutable(
|
||||
path.join(binDir, "openclaw"),
|
||||
`#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
|
||||
const capturePath = process.env.OPENCLAW_CAPTURE_PATH;
|
||||
const args = process.argv.slice(2);
|
||||
let entries = [];
|
||||
if (capturePath && fs.existsSync(capturePath)) {
|
||||
entries = JSON.parse(fs.readFileSync(capturePath, "utf8"));
|
||||
}
|
||||
entries.push(args);
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
||||
}
|
||||
|
||||
if (args[0] === "--version") {
|
||||
process.stdout.write("openclaw test\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "cron" && args[1] === "list") {
|
||||
process.stdout.write(JSON.stringify({ jobs: [] }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "cron" && args[1] === "add") {
|
||||
process.stdout.write(JSON.stringify({ id: "cron-123" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "cron" && args[1] === "edit") {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "hooks" && args[1] === "enable") {
|
||||
process.stdout.write("enabled\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write("unexpected args: " + JSON.stringify(args) + "\\n");
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
|
||||
return { tmp, binDir, capturePath };
|
||||
}
|
||||
|
||||
async function runNodeScript(scriptPath, env) {
|
||||
return await new Promise((resolve) => {
|
||||
const proc = spawn(NODE_BIN, [scriptPath], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", async (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function testAdvisoryCronPreflight() {
|
||||
const testName = "setup_advisory_cron: prints preflight review before creating unattended cron";
|
||||
const fixture = await createOpenClawFixture();
|
||||
|
||||
try {
|
||||
const result = await runNodeScript(SETUP_CRON_SCRIPT, {
|
||||
...process.env,
|
||||
PATH: `${fixture.binDir}:${process.env.PATH || ""}`,
|
||||
OPENCLAW_CAPTURE_PATH: fixture.capturePath,
|
||||
CLAWSEC_ADVISORY_CRON_EVERY: "6h",
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `script failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const captures = JSON.parse(await fs.readFile(fixture.capturePath, "utf8"));
|
||||
const sawAdd = captures.some((args) => args[0] === "cron" && args[1] === "add");
|
||||
|
||||
if (
|
||||
sawAdd &&
|
||||
result.stdout.includes("Preflight review:") &&
|
||||
result.stdout.includes("unattended openclaw cron job") &&
|
||||
result.stdout.includes("Schedule: every 6h") &&
|
||||
result.stdout.includes("request explicit approval before any removal")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `missing preflight details: ${result.stdout}`);
|
||||
}
|
||||
} finally {
|
||||
await fixture.tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function testAdvisoryHookPreflight() {
|
||||
const testName = "setup_advisory_hook: prints preflight review before installing persistent hook";
|
||||
const fixture = await createOpenClawFixture();
|
||||
const homeDir = path.join(fixture.tmp.path, "home");
|
||||
|
||||
try {
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
|
||||
const result = await runNodeScript(SETUP_HOOK_SCRIPT, {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
PATH: `${fixture.binDir}:${process.env.PATH || ""}`,
|
||||
OPENCLAW_CAPTURE_PATH: fixture.capturePath,
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `script failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const installedHook = path.join(homeDir, ".openclaw", "hooks", "clawsec-advisory-guardian", "HOOK.md");
|
||||
const captures = JSON.parse(await fs.readFile(fixture.capturePath, "utf8"));
|
||||
const sawEnable = captures.some((args) => args[0] === "hooks" && args[1] === "enable");
|
||||
|
||||
await fs.access(installedHook);
|
||||
|
||||
if (
|
||||
sawEnable &&
|
||||
result.stdout.includes("Preflight review:") &&
|
||||
result.stdout.includes("persistent OpenClaw hook") &&
|
||||
result.stdout.includes("fetches signed advisory feed data") &&
|
||||
result.stdout.includes("Restart your OpenClaw gateway process")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `missing hook preflight details: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await fixture.tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
await testAdvisoryCronPreflight();
|
||||
await testAdvisoryHookPreflight();
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
console.error("Test runner failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Clawtributor will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.4] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes that describe the standalone install runtime and the external GitHub submission target.
|
||||
- Metadata that records opt-in reporting, local state persistence, and approval-gated network egress.
|
||||
|
||||
### Changed
|
||||
|
||||
- Corrected the skill homepage in `SKILL.md` to the canonical `clawsec.prompt.security` domain.
|
||||
- Declared the full standalone install/reporting toolchain (`bash`, `curl`, `jq`, `shasum`, `unzip`, `gh`) in metadata.
|
||||
|
||||
### Security
|
||||
|
||||
- Made the off-host reporting trust model explicit: every submission stays approval-gated and evidence must be sanitized before it is sent to GitHub.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Community incident reporting for AI agents. Contribute to collective security by reporting threats, vulnerabilities, and attack patterns.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Reporting is opt-in for every submission
|
||||
- Required runtime for full standalone flow: `bash`, `curl`, `jq`, `shasum`, `unzip`, `gh`
|
||||
- External submission target: Prompt Security GitHub Issues, only after user approval
|
||||
- Review and sanitize report content before submission because evidence leaves the local host
|
||||
|
||||
## Features
|
||||
|
||||
- **Opt-in Reporting** - All submissions require explicit user approval
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
---
|
||||
name: clawtributor
|
||||
version: 0.0.3
|
||||
version: 0.0.4
|
||||
description: Community incident reporting for AI agents. Contribute to collective security by reporting threats.
|
||||
homepage: https://gclawsec.prompt.security
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🤝","category":"security"}}
|
||||
clawdis:
|
||||
emoji: "🤝"
|
||||
requires:
|
||||
bins: [curl, git, gh]
|
||||
bins: [bash, curl, jq, shasum, unzip, gh]
|
||||
---
|
||||
|
||||
# Clawtributor 🤝
|
||||
|
||||
Community incident reporting for AI agents. Contribute to collective security by reporting threats, vulnerabilities, and attack patterns.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime for standalone install/report submission: `bash`, `curl`, `jq`, `shasum`, `unzip`, `gh`
|
||||
- Side effects: writes local report/state files and, after explicit user approval, submits GitHub Issues to the Prompt Security repository
|
||||
- Network behavior: downloads release artifacts and optionally sends approved reports to GitHub
|
||||
- Trust model: reporting is opt-in for every submission; sanitize evidence before sending it off-host
|
||||
|
||||
**An open source project by [Prompt Security](https://prompt.security)**
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawtributor",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"description": "Community incident reporting for AI agents. Contribute to collective security by reporting threats.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -21,6 +21,11 @@
|
||||
"required": true,
|
||||
"description": "Community reporting skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "reporting.md",
|
||||
"required": true,
|
||||
@@ -33,11 +38,24 @@
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"bash",
|
||||
"curl",
|
||||
"git",
|
||||
"jq",
|
||||
"shasum",
|
||||
"unzip",
|
||||
"gh"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "Stores local report/state files only; no recurring automation is created by default.",
|
||||
"network_egress": "Submits GitHub Issues to the Prompt Security repository only after explicit user approval."
|
||||
},
|
||||
"operator_review": [
|
||||
"Reporting is opt-in and should remain approval-gated for every submission.",
|
||||
"Review and sanitize report content before submitting because reports leave the host and become visible to maintainers.",
|
||||
"GitHub CLI authentication is required for issue submission; do not reuse unrelated credentials."
|
||||
],
|
||||
"triggers": [
|
||||
"report vulnerability",
|
||||
"report attack",
|
||||
|
||||
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.2] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Registry/runtime metadata now declares the actual required runtimes (`openclaw`, `node`) plus the DM/email environment variables and operator review notes.
|
||||
- `scripts/setup_cron.mjs` now prints a preflight review summarizing recipients, persistence, and required runtime before creating or updating the cron job.
|
||||
- Coverage for cron setup disclosure behavior (`test/setup_cron.test.mjs`) and case-insensitive suppression matching regression.
|
||||
|
||||
### Changed
|
||||
|
||||
- Email delivery is now explicit and opt-in: `scripts/runner.sh` only attempts email delivery when `PROMPTSEC_EMAIL_TO` is configured.
|
||||
- `scripts/setup_cron.mjs` now carries configured runtime/delivery environment variables into the cron payload so the scheduled job is more self-describing and less dependent on ambient host state.
|
||||
- Suppression matching in `scripts/render_report.mjs` is now case-insensitive for skill names, matching the documented behavior and normalized config loader.
|
||||
- Documentation now consistently refers to the current OpenClaw product name.
|
||||
|
||||
### Security
|
||||
|
||||
- Removed the placeholder email recipient from the default cron payload to avoid implicitly sending audit output to an unreviewed address.
|
||||
- Cron setup now surfaces the unattended delivery model before enabling persistence, making external recipients and runtime assumptions explicit to the operator.
|
||||
|
||||
## [0.1.1]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
# OpenClaw Audit Watchdog 🔭
|
||||
|
||||
Automated daily security audits for OpenClaw/Clawdbot agents with email reporting.
|
||||
Automated daily security audits for OpenClaw agents with DM delivery and optional email reporting.
|
||||
|
||||
## Overview
|
||||
|
||||
The Audit Watchdog provides automated security monitoring for your OpenClaw agent deployments:
|
||||
|
||||
- **Daily Security Scans** - Scheduled via cron for continuous monitoring
|
||||
- **Daily Security Scans** - Scheduled via `openclaw cron` for continuous monitoring
|
||||
- **Deep Audit Mode** - Comprehensive analysis of agent configurations and behavior
|
||||
- **Email Reporting** - Formatted reports delivered to your security team
|
||||
- **DM Delivery** - Reports are posted to the configured delivery target
|
||||
- **Optional Email Reporting** - Email is only attempted when `PROMPTSEC_EMAIL_TO` is configured
|
||||
- **Git Integration** - Optionally syncs latest configurations before audit
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `openclaw`, `node`, `bash`
|
||||
- Optional runtime: `sendmail` or an SMTP relay configured with `PROMPTSEC_SMTP_*`
|
||||
- Persistence: `scripts/setup_cron.mjs` creates or updates an unattended recurring `openclaw cron` job
|
||||
- External delivery: reports go to the configured DM target and optionally to the configured email recipient, so review those recipients before enabling automation
|
||||
- Provenance: standalone installation downloads a release archive; verify the release source and integrity before installing on production hosts
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -23,6 +32,8 @@ curl -sSL "https://github.com/prompt-security/clawsec/releases/download/$VERSION
|
||||
unzip watchdog.skill
|
||||
|
||||
# Configure
|
||||
export PROMPTSEC_DM_CHANNEL="telegram"
|
||||
export PROMPTSEC_DM_TO="@security-team"
|
||||
export PROMPTSEC_EMAIL_TO="security@yourcompany.com"
|
||||
export PROMPTSEC_HOST_LABEL="prod-agent-1"
|
||||
|
||||
@@ -34,10 +45,19 @@ export PROMPTSEC_HOST_LABEL="prod-agent-1"
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PROMPTSEC_EMAIL_TO` | Email recipient for reports | `target@example.com` |
|
||||
| `PROMPTSEC_DM_CHANNEL` | DM delivery channel used by cron setup | Required for cron setup |
|
||||
| `PROMPTSEC_DM_TO` | DM recipient/handle used by cron setup | Required for cron setup |
|
||||
| `PROMPTSEC_EMAIL_TO` | Email recipient for reports | Disabled unless set |
|
||||
| `PROMPTSEC_TZ` | Timezone for cron setup | `UTC` |
|
||||
| `PROMPTSEC_HOST_LABEL` | Host identifier in reports | hostname |
|
||||
| `PROMPTSEC_INSTALL_DIR` | Path used by cron payload before running `runner.sh` | `~/.config/security-checkup` |
|
||||
| `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` |
|
||||
| `OPENCLAW_AUDIT_CONFIG` | Path to suppression config file | Auto-detected |
|
||||
| `PROMPTSEC_SENDMAIL_BIN` | Explicit sendmail-compatible binary path | Auto-detected |
|
||||
| `PROMPTSEC_SMTP_HOST` | SMTP relay host for fallback delivery | Unset |
|
||||
| `PROMPTSEC_SMTP_PORT` | SMTP relay port for fallback delivery | `25` |
|
||||
| `PROMPTSEC_SMTP_HELO` | SMTP EHLO/HELO name | hostname |
|
||||
| `PROMPTSEC_SMTP_FROM` | SMTP sender address | `security-checkup@<hostname>` |
|
||||
|
||||
### Path Expansion and Quoting
|
||||
|
||||
@@ -170,9 +190,8 @@ See `examples/security-audit-config.example.json` for a complete template.
|
||||
|
||||
## Requirements
|
||||
|
||||
- bash
|
||||
- curl
|
||||
- Optional: node (for SMTP/rendering), jq (for JSON), sendmail (for email)
|
||||
- Required: `bash`, `openclaw`, `node`
|
||||
- Optional: `curl` (download/install flow), `git` (`PROMPTSEC_GIT_PULL=1`), `sendmail`, or an SMTP relay (`PROMPTSEC_SMTP_*`)
|
||||
|
||||
## Cron Setup
|
||||
|
||||
@@ -187,6 +206,14 @@ Or use the setup script:
|
||||
node scripts/setup_cron.mjs
|
||||
```
|
||||
|
||||
The setup script now prints a preflight review before creating or updating the cron job so the operator can verify:
|
||||
|
||||
- the unattended persistence model,
|
||||
- the required runtime on the host,
|
||||
- the DM target,
|
||||
- whether email is enabled and which recipient it will use,
|
||||
- the install directory and timezone that will be baked into the cron payload.
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - See [LICENSE](../../LICENSE) for details.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: openclaw-audit-watchdog
|
||||
version: 0.1.1
|
||||
description: Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.
|
||||
version: 0.1.2
|
||||
description: Automated daily security audits for OpenClaw agents with DM delivery and optional email reporting. Runs deep audits, creates or updates a recurring cron job, and sends formatted reports to configured recipients.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🔭","category":"security"}}
|
||||
clawdis:
|
||||
emoji: "🔭"
|
||||
requires:
|
||||
bins: [bash, curl]
|
||||
bins: [bash, curl, openclaw, node]
|
||||
---
|
||||
|
||||
# Prompt Security Audit (openclaw)
|
||||
@@ -42,10 +42,26 @@ Install openclaw-audit-watchdog independently without the full suite.
|
||||
- Independent from suite
|
||||
- Direct control over installation process
|
||||
|
||||
Standalone installation usually involves a network download from the published GitHub release. Verify the release source and archive integrity before installing it on production hosts.
|
||||
|
||||
Continue below for standalone installation instructions.
|
||||
|
||||
---
|
||||
|
||||
## Operational requirements
|
||||
|
||||
Required runtime:
|
||||
- `openclaw`
|
||||
- `node`
|
||||
- `bash`
|
||||
|
||||
Optional runtime:
|
||||
- `sendmail` for local MTA delivery
|
||||
- SMTP relay via `PROMPTSEC_SMTP_HOST` / `PROMPTSEC_SMTP_PORT`
|
||||
- `git` only if `PROMPTSEC_GIT_PULL=1`
|
||||
|
||||
This skill is not `always`-on by default, but when invoked it creates or updates an unattended `openclaw cron` job. Review the configured DM/email recipients and the host's `openclaw`/SMTP environment before enabling it.
|
||||
|
||||
## Goal
|
||||
|
||||
Create (or update) a daily cron job that:
|
||||
@@ -58,11 +74,14 @@ Create (or update) a daily cron job that:
|
||||
|
||||
3) Sends the report to:
|
||||
- a user-selected DM target (channel + recipient id/handle)
|
||||
- an optional email recipient only when `PROMPTSEC_EMAIL_TO` is configured
|
||||
|
||||
Default schedule: **daily at 23:00 (11pm)** in the chosen timezone.
|
||||
|
||||
Delivery:
|
||||
- DM to last active session
|
||||
- DM to the configured target
|
||||
- Optional email only when an explicit recipient is configured
|
||||
- Persistence via `openclaw cron` (unattended recurring job)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -73,6 +92,7 @@ For automated/MDM deployments, set environment variables before invoking:
|
||||
```bash
|
||||
export PROMPTSEC_DM_CHANNEL="telegram"
|
||||
export PROMPTSEC_DM_TO="@yourhandle"
|
||||
export PROMPTSEC_EMAIL_TO="security@yourcompany.com" # optional
|
||||
export PROMPTSEC_TZ="America/New_York"
|
||||
export PROMPTSEC_HOST_LABEL="prod-server-01"
|
||||
|
||||
@@ -80,7 +100,7 @@ export PROMPTSEC_HOST_LABEL="prod-server-01"
|
||||
/openclaw-audit-watchdog
|
||||
```
|
||||
|
||||
The skill will automatically configure and create the cron job without prompts.
|
||||
The skill will automatically configure and create the cron job without prompts. If `PROMPTSEC_EMAIL_TO` is omitted, the job remains DM-only.
|
||||
|
||||
### Example 2: Interactive Setup
|
||||
|
||||
@@ -96,12 +116,15 @@ User: telegram
|
||||
Agent: What's the recipient ID or handle?
|
||||
User: @myhandle
|
||||
|
||||
Agent: Optional email recipient? (leave blank to disable email)
|
||||
User: security@yourcompany.com
|
||||
|
||||
Agent: Which timezone for the 23:00 daily run? (default: UTC)
|
||||
User: America/Los_Angeles
|
||||
|
||||
Agent: ✓ Created cron job "Daily security audit (Prompt Security)"
|
||||
Schedule: Daily at 23:00 America/Los_Angeles
|
||||
Delivery: telegram → @myhandle
|
||||
Delivery: telegram → @myhandle, email → security@yourcompany.com
|
||||
```
|
||||
|
||||
### Example 3: Updating Existing Job
|
||||
@@ -266,10 +289,14 @@ Required env:
|
||||
- `PROMPTSEC_DM_TO` (recipient id)
|
||||
|
||||
Optional env:
|
||||
- `PROMPTSEC_EMAIL_TO` (email recipient; if unset, email delivery stays disabled)
|
||||
- `PROMPTSEC_TZ` (IANA timezone; default `UTC`)
|
||||
- `PROMPTSEC_HOST_LABEL` (label included in report; default uses `hostname`)
|
||||
- `PROMPTSEC_INSTALL_DIR` (stable path used by cron payload to `cd` before running runner; default: `~/.config/security-checkup`)
|
||||
- `PROMPTSEC_GIT_PULL=1` (runner will `git pull --ff-only` if installed from git)
|
||||
- `OPENCLAW_AUDIT_CONFIG` (suppression config path to persist into the cron payload)
|
||||
- `PROMPTSEC_SENDMAIL_BIN` (explicit sendmail path)
|
||||
- `PROMPTSEC_SMTP_HOST`, `PROMPTSEC_SMTP_PORT`, `PROMPTSEC_SMTP_HELO`, `PROMPTSEC_SMTP_FROM` (SMTP relay settings)
|
||||
|
||||
Path expansion rules (important):
|
||||
- In `bash`/`zsh`, use `PROMPTSEC_INSTALL_DIR="$HOME/.config/security-checkup"` (or absolute path).
|
||||
@@ -277,9 +304,7 @@ Path expansion rules (important):
|
||||
- On PowerShell, prefer: `$env:PROMPTSEC_INSTALL_DIR = Join-Path $HOME ".config/security-checkup"`.
|
||||
- If path resolution fails, setup now exits with a clear error instead of creating a literal `$HOME` directory segment.
|
||||
|
||||
Interactive install is last resort if env vars or defaults are not set.
|
||||
|
||||
even in that case keep prompts minimalistic the watchdog tool is pretty straight up configured out of the box.
|
||||
Interactive install is last resort if env vars or defaults are not set. Keep prompts minimal: DM target is required, email is optional, and the user should see a concise preflight review before persistence is enabled.
|
||||
|
||||
## Create the cron job
|
||||
|
||||
@@ -293,6 +318,13 @@ Use the `cron` tool to create a job with:
|
||||
- `payload.kind="agentTurn"`
|
||||
- `payload.deliver=true`
|
||||
|
||||
Before creating or updating the job, print a preflight review that explicitly states:
|
||||
- this action creates or updates an unattended recurring job,
|
||||
- the required runtime (`openclaw`, `node`, `bash`),
|
||||
- the configured DM target,
|
||||
- whether email is enabled and to which recipient,
|
||||
- the install directory and timezone used for execution.
|
||||
|
||||
### Payload message template (agentTurn)
|
||||
|
||||
Create the job with a payload message that instructs the isolated run to:
|
||||
@@ -317,16 +349,22 @@ Include:
|
||||
|
||||
### Email delivery requirement
|
||||
|
||||
Attempt email delivery in this priority order:
|
||||
Email delivery is optional. Only promise or attempt it when `PROMPTSEC_EMAIL_TO` is configured.
|
||||
|
||||
A) If an email channel plugin exists in this deployment, use:
|
||||
- `message(action="send", channel="email", target="target@example.com", message=<report>)`
|
||||
If `PROMPTSEC_EMAIL_TO` is set, attempt delivery in this priority order:
|
||||
|
||||
B) Otherwise, fallback to local sendmail if available:
|
||||
- `exec` with: `printf "%s" "$REPORT" | /usr/sbin/sendmail -t` (construct To/Subject headers)
|
||||
A) If a local sendmail-compatible binary is available, use it first.
|
||||
|
||||
B) Otherwise, fallback to the configured SMTP relay:
|
||||
- `PROMPTSEC_SMTP_HOST`
|
||||
- `PROMPTSEC_SMTP_PORT`
|
||||
- optional `PROMPTSEC_SMTP_HELO`
|
||||
- optional `PROMPTSEC_SMTP_FROM`
|
||||
|
||||
If neither path is possible, still DM the user and include a line:
|
||||
- `"NOTE: could not deliver to target@example.com (email channel not configured)"`
|
||||
- `"NOTE: could not deliver email to <PROMPTSEC_EMAIL_TO> via configured sendmail/SMTP path"`
|
||||
|
||||
If `PROMPTSEC_EMAIL_TO` is not set, the cron payload must explicitly describe email as disabled rather than implying a default recipient.
|
||||
|
||||
## Idempotency / updates
|
||||
|
||||
|
||||
@@ -60,9 +60,15 @@ function extractSkillName(finding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSkillName(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized ? normalized.toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter findings into active and suppressed based on suppression config.
|
||||
* Matches require BOTH checkId AND skill name to match (exact match).
|
||||
* Matches require BOTH checkId AND skill name to match.
|
||||
* checkId remains exact; skill name is normalized case-insensitively.
|
||||
*
|
||||
* @param {Array} findings - Array of finding objects
|
||||
* @param {Array} suppressions - Array of suppression rules
|
||||
@@ -83,17 +89,17 @@ function filterFindings(findings, suppressions) {
|
||||
for (const finding of findings) {
|
||||
const checkId = finding?.checkId ?? "";
|
||||
const skillName = extractSkillName(finding);
|
||||
const normalizedSkillName = normalizeSkillName(skillName);
|
||||
|
||||
// Check if this finding matches any suppression rule
|
||||
const isSuppressed = suppressions.some((rule) => {
|
||||
// BOTH checkId AND skill must match (exact match, case-sensitive)
|
||||
return rule.checkId === checkId && rule.skill === skillName;
|
||||
return rule.checkId === checkId && normalizeSkillName(rule.skill) === normalizedSkillName;
|
||||
});
|
||||
|
||||
if (isSuppressed) {
|
||||
// Find the matching rule to attach suppression metadata
|
||||
const matchingRule = suppressions.find(
|
||||
(rule) => rule.checkId === checkId && rule.skill === skillName
|
||||
(rule) => rule.checkId === checkId && normalizeSkillName(rule.skill) === normalizedSkillName
|
||||
);
|
||||
suppressed.push({
|
||||
...finding,
|
||||
|
||||
@@ -4,10 +4,10 @@ set -euo pipefail
|
||||
# Runner for Prompt Security daily audit job.
|
||||
# - Optionally git-pulls repo (if PROMPTSEC_GIT_PULL=1)
|
||||
# - Runs openclaw security audit + deep audit
|
||||
# - Emails report to target@example.com via local sendmail
|
||||
# - Optionally emails the report if PROMPTSEC_EMAIL_TO is configured
|
||||
# - Prints the report to stdout (so cron delivery can DM it)
|
||||
|
||||
COMPANY_EMAIL="${PROMPTSEC_EMAIL_TO:-target@example.com}"
|
||||
COMPANY_EMAIL="${PROMPTSEC_EMAIL_TO:-}"
|
||||
HOST_LABEL="${PROMPTSEC_HOST_LABEL:-}"
|
||||
DO_PULL="${PROMPTSEC_GIT_PULL:-0}"
|
||||
ENABLE_SUPPRESSIONS=0
|
||||
@@ -49,24 +49,27 @@ REPORT="$($SCRIPT_DIR/run_audit_and_format.sh "${args[@]}")"
|
||||
SUBJECT_HOST="${HOST_LABEL:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown-host)}"
|
||||
EMAIL_OK=1
|
||||
|
||||
# Prefer sendmail-compatible delivery if available; otherwise fallback to local SMTP (localhost:25 by default).
|
||||
if printf '%s\n' "$REPORT" | "$SCRIPT_DIR/sendmail_report.sh" --to "$COMPANY_EMAIL" --subject "[$SUBJECT_HOST] openclaw daily security audit"; then
|
||||
EMAIL_OK=1
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
if printf '%s\n' "$REPORT" | node "$SCRIPT_DIR/send_smtp.mjs" --to "$COMPANY_EMAIL" --subject "[$SUBJECT_HOST] openclaw daily security audit"; then
|
||||
EMAIL_OK=1
|
||||
if [[ -n "$COMPANY_EMAIL" ]]; then
|
||||
EMAIL_OK=0
|
||||
# Prefer sendmail-compatible delivery if available; otherwise fallback to local SMTP (localhost:25 by default).
|
||||
if printf '%s\n' "$REPORT" | "$SCRIPT_DIR/sendmail_report.sh" --to "$COMPANY_EMAIL" --subject "[$SUBJECT_HOST] openclaw daily security audit"; then
|
||||
EMAIL_OK=1
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
if printf '%s\n' "$REPORT" | node "$SCRIPT_DIR/send_smtp.mjs" --to "$COMPANY_EMAIL" --subject "[$SUBJECT_HOST] openclaw daily security audit"; then
|
||||
EMAIL_OK=1
|
||||
else
|
||||
EMAIL_OK=0
|
||||
fi
|
||||
else
|
||||
EMAIL_OK=0
|
||||
fi
|
||||
else
|
||||
EMAIL_OK=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$EMAIL_OK" -eq 0 ]]; then
|
||||
if [[ -n "$COMPANY_EMAIL" && "$EMAIL_OK" -eq 0 ]]; then
|
||||
printf '%s\n\n' "$REPORT"
|
||||
echo "NOTE: could not deliver email to ${COMPANY_EMAIL} via local sendmail"
|
||||
echo "NOTE: could not deliver email to ${COMPANY_EMAIL} via configured sendmail/SMTP path"
|
||||
else
|
||||
printf '%s\n' "$REPORT"
|
||||
fi
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
# Sends report text (stdin) via local sendmail.
|
||||
#
|
||||
# Usage:
|
||||
# ./sendmail_report.sh --to target@example.com [--subject "..."]
|
||||
# ./sendmail_report.sh --to security@example.com [--subject "..."]
|
||||
|
||||
TO=""
|
||||
SUBJECT="openclaw daily security audit"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Setup: create/update a daily 23:00 cron job that
|
||||
* - runs openclaw security audits
|
||||
* - DMs a chosen recipient (channel+id)
|
||||
* - emails target@example.com via local sendmail
|
||||
* - optionally emails a configured recipient via sendmail/SMTP
|
||||
*
|
||||
* Uses the `openclaw cron` CLI so it can run on a host without direct Gateway RPC access.
|
||||
*/
|
||||
@@ -16,9 +16,18 @@ import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const JOB_NAME = "Daily security audit (Prompt Security)";
|
||||
const COMPANY_EMAIL = "target@example.com";
|
||||
const DEFAULT_TZ = "UTC";
|
||||
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
|
||||
const PERSISTED_ENV_KEYS = [
|
||||
"PROMPTSEC_EMAIL_TO",
|
||||
"PROMPTSEC_GIT_PULL",
|
||||
"OPENCLAW_AUDIT_CONFIG",
|
||||
"PROMPTSEC_SENDMAIL_BIN",
|
||||
"PROMPTSEC_SMTP_HOST",
|
||||
"PROMPTSEC_SMTP_PORT",
|
||||
"PROMPTSEC_SMTP_HELO",
|
||||
"PROMPTSEC_SMTP_FROM",
|
||||
];
|
||||
|
||||
const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const UNEXPANDED_HOME_TOKEN_PATTERN =
|
||||
@@ -115,6 +124,65 @@ function escapeForShellEnvVar(v) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildRunnerEnv({ hostLabel, emailTo }) {
|
||||
const envVars = {
|
||||
PROMPTSEC_HOST_LABEL: hostLabel,
|
||||
};
|
||||
|
||||
if (emailTo) {
|
||||
envVars.PROMPTSEC_EMAIL_TO = emailTo;
|
||||
}
|
||||
|
||||
for (const key of PERSISTED_ENV_KEYS) {
|
||||
const value = envOrEmpty(key);
|
||||
if (value) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function buildRunnerCommand({ installDir, hostLabel, emailTo }) {
|
||||
const envVars = buildRunnerEnv({ hostLabel, emailTo });
|
||||
const exports = Object.entries(envVars)
|
||||
.filter(([, value]) => String(value ?? "").trim() !== "")
|
||||
.map(([key, value]) => `${key}="${escapeForShellEnvVar(value)}"`);
|
||||
|
||||
const exportPrefix = exports.length ? `${exports.join(" ")} ` : "";
|
||||
return `cd "${escapeForShellEnvVar(installDir || "")}" && ${exportPrefix}./scripts/runner.sh`;
|
||||
}
|
||||
|
||||
function printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel }) {
|
||||
const emailSummary = emailTo || "disabled (set PROMPTSEC_EMAIL_TO to enable)";
|
||||
const persistedKeys = Array.from(new Set([
|
||||
"PROMPTSEC_HOST_LABEL",
|
||||
emailTo ? "PROMPTSEC_EMAIL_TO" : null,
|
||||
...PERSISTED_ENV_KEYS.filter((key) => envOrEmpty(key)),
|
||||
].filter(Boolean)));
|
||||
|
||||
const lines = [
|
||||
"Preflight review:",
|
||||
"- This setup creates or updates an unattended openclaw cron job.",
|
||||
"- Required runtime: openclaw CLI, node, bash.",
|
||||
"- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.",
|
||||
`- DM target: ${oneline(dmChannel)}:${oneline(dmTo)}`,
|
||||
`- Email target: ${oneline(emailSummary)}`,
|
||||
`- Schedule: ${DEFAULT_EXPR} (${oneline(tz)})`,
|
||||
`- Install dir: ${oneline(installDir)}`,
|
||||
];
|
||||
|
||||
if (hostLabel) {
|
||||
lines.push(`- Host label: ${oneline(hostLabel)}`);
|
||||
}
|
||||
|
||||
if (persistedKeys.length) {
|
||||
lines.push(`- Cron payload persists env: ${persistedKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join("\n") + "\n\n");
|
||||
}
|
||||
|
||||
function defaultInstallDir() {
|
||||
const env = envOrEmpty("PROMPTSEC_INSTALL_DIR");
|
||||
if (env) return resolveUserPath(env, "PROMPTSEC_INSTALL_DIR");
|
||||
@@ -123,26 +191,38 @@ function defaultInstallDir() {
|
||||
return resolveUserPath(SCRIPT_ROOT, "script root");
|
||||
}
|
||||
|
||||
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
|
||||
const safeDir = escapeForShellEnvVar(installDir || "");
|
||||
const escapedHostLabel = escapeForShellEnvVar(hostLabel);
|
||||
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir, emailTo }) {
|
||||
const runnerCommand = buildRunnerCommand({ installDir, hostLabel, emailTo });
|
||||
const emailLine = emailTo
|
||||
? `Email: ${oneline(emailTo)} (sendmail first, SMTP fallback if configured)`
|
||||
: "Email: disabled unless PROMPTSEC_EMAIL_TO is set";
|
||||
|
||||
return [
|
||||
"Run daily openclaw security audits and deliver report (DM + email).",
|
||||
"Run daily openclaw security audits and deliver report to the configured recipients.",
|
||||
"",
|
||||
"Dependencies:",
|
||||
"- Required runtime: openclaw CLI, node, bash.",
|
||||
"- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.",
|
||||
"",
|
||||
"Configured delivery:",
|
||||
`Delivery DM: ${oneline(dmChannel)}:${oneline(dmTo)}`,
|
||||
`Email: ${COMPANY_EMAIL} (local sendmail)`,
|
||||
emailLine,
|
||||
"",
|
||||
"Execute:",
|
||||
`- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${escapedHostLabel}" ./scripts/runner.sh`,
|
||||
`- Run via exec: ${runnerCommand}`,
|
||||
"",
|
||||
"Output requirements:",
|
||||
"- Print the report to stdout (cron deliver will DM it).",
|
||||
`- Also email the same report to ${COMPANY_EMAIL}; if email fails, append a NOTE line to stdout.`,
|
||||
"- If PROMPTSEC_EMAIL_TO is set, email the same report to that address; if email fails, append a NOTE line to stdout.",
|
||||
"- Do not apply fixes automatically.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildDescription({ dmChannel, dmTo, emailTo }) {
|
||||
const emailPart = emailTo ? `; email ${emailTo}` : "; email disabled unless configured";
|
||||
return `Runs openclaw security audit daily and delivers to ${dmChannel}:${dmTo}${emailPart}.`;
|
||||
}
|
||||
|
||||
function findExistingJobId(listJson) {
|
||||
const jobs = Array.isArray(listJson?.jobs) ? listJson.jobs : [];
|
||||
const match = jobs.find((j) => j?.name === JOB_NAME);
|
||||
@@ -155,6 +235,7 @@ async function run() {
|
||||
const dmChannelEnv = envOrEmpty("PROMPTSEC_DM_CHANNEL");
|
||||
const dmToEnv = envOrEmpty("PROMPTSEC_DM_TO");
|
||||
const hostLabelEnv = envOrEmpty("PROMPTSEC_HOST_LABEL");
|
||||
const emailToEnv = envOrEmpty("PROMPTSEC_EMAIL_TO");
|
||||
|
||||
const interactive = !(tzEnv && dmChannelEnv && dmToEnv);
|
||||
|
||||
@@ -173,6 +254,9 @@ async function run() {
|
||||
const hostLabel = interactive
|
||||
? await prompt("Optional host label to include in report", { defaultValue: hostLabelEnv })
|
||||
: hostLabelEnv;
|
||||
const emailTo = interactive
|
||||
? await prompt("Optional email recipient (leave empty to disable email)", { defaultValue: emailToEnv })
|
||||
: emailToEnv;
|
||||
|
||||
const installDirDefault = defaultInstallDir();
|
||||
const installDirInput = interactive
|
||||
@@ -189,12 +273,14 @@ async function run() {
|
||||
throw new Error(`runner.sh not found at ${runnerPath}; set PROMPTSEC_INSTALL_DIR to the deployed path`);
|
||||
}
|
||||
|
||||
printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel });
|
||||
|
||||
const listOut = sh("openclaw", ["cron", "list", "--json"]);
|
||||
const listJson = JSON.parse(listOut);
|
||||
const existingId = findExistingJobId(listJson);
|
||||
|
||||
const agentMessage = buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir });
|
||||
const description = `Runs openclaw security audit daily and delivers to ${dmChannel}:${dmTo} + ${COMPANY_EMAIL}.`;
|
||||
const agentMessage = buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir, emailTo });
|
||||
const description = buildDescription({ dmChannel, dmTo, emailTo });
|
||||
|
||||
if (!existingId) {
|
||||
const args = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openclaw-audit-watchdog",
|
||||
"version": "0.1.1",
|
||||
"description": "Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.",
|
||||
"version": "0.1.2",
|
||||
"description": "Automated daily security audits for OpenClaw agents with DM delivery and optional email reporting. Creates or updates an unattended cron job and sends formatted reports to configured recipients.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
@@ -65,9 +65,53 @@
|
||||
"requires": {
|
||||
"bins": [
|
||||
"bash",
|
||||
"curl"
|
||||
"curl",
|
||||
"openclaw",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [
|
||||
"PROMPTSEC_DM_CHANNEL",
|
||||
"PROMPTSEC_DM_TO"
|
||||
],
|
||||
"optional_env": [
|
||||
"PROMPTSEC_EMAIL_TO",
|
||||
"PROMPTSEC_TZ",
|
||||
"PROMPTSEC_HOST_LABEL",
|
||||
"PROMPTSEC_INSTALL_DIR",
|
||||
"PROMPTSEC_GIT_PULL",
|
||||
"OPENCLAW_AUDIT_CONFIG",
|
||||
"PROMPTSEC_SENDMAIL_BIN",
|
||||
"PROMPTSEC_SMTP_HOST",
|
||||
"PROMPTSEC_SMTP_PORT",
|
||||
"PROMPTSEC_SMTP_HELO",
|
||||
"PROMPTSEC_SMTP_FROM"
|
||||
],
|
||||
"optional_bins": [
|
||||
"git",
|
||||
"sendmail"
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"dm": "required",
|
||||
"email": "optional via PROMPTSEC_EMAIL_TO",
|
||||
"email_transport": [
|
||||
"local sendmail",
|
||||
"SMTP relay configured with PROMPTSEC_SMTP_*"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "Creates or updates a recurring openclaw cron job when setup is run.",
|
||||
"network_egress": "Reports are delivered to the configured DM target and optionally to the configured email recipient."
|
||||
},
|
||||
"operator_review": [
|
||||
"Verify the openclaw CLI and node runtime on the host before enabling the cron job.",
|
||||
"Review DM and email recipients before installing because reports are delivered externally.",
|
||||
"If email is enabled, verify the local sendmail binary or PROMPTSEC_SMTP_* relay settings.",
|
||||
"Suppressions require both --enable-suppressions and enabledFor: [\"audit\"] in config."
|
||||
],
|
||||
"triggers": [
|
||||
"audit watchdog",
|
||||
"security audit",
|
||||
|
||||
@@ -598,6 +598,62 @@ async function testSkillNameExtractionFromTitle() {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Skill name matching is case-insensitive
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testSkillNameMatchingIsCaseInsensitive() {
|
||||
const testName = "render_report: suppression skill matching is case-insensitive";
|
||||
try {
|
||||
const auditFile = path.join(tempDir, "audit.json");
|
||||
const deepFile = path.join(tempDir, "deep.json");
|
||||
const configFile = path.join(tempDir, "config.json");
|
||||
|
||||
const findings = [
|
||||
{
|
||||
severity: "critical",
|
||||
checkId: "skills.code_safety",
|
||||
skill: "ClawSec-Suite",
|
||||
title: "dangerous-exec detected",
|
||||
},
|
||||
];
|
||||
|
||||
const suppressions = [
|
||||
{
|
||||
checkId: "skills.code_safety",
|
||||
skill: "clawsec-suite",
|
||||
reason: "First-party security tooling",
|
||||
suppressedAt: "2026-02-13",
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(auditFile, createAuditJson(findings));
|
||||
await fs.writeFile(deepFile, createAuditJson([]));
|
||||
await fs.writeFile(configFile, createConfigJson(suppressions));
|
||||
|
||||
const result = await runRenderReport([
|
||||
"--audit",
|
||||
auditFile,
|
||||
"--deep",
|
||||
deepFile,
|
||||
"--enable-suppressions",
|
||||
"--config",
|
||||
configFile,
|
||||
]);
|
||||
|
||||
if (
|
||||
result.stdout.includes("Summary: 0 critical") &&
|
||||
result.stdout.includes("INFO-SUPPRESSED:") &&
|
||||
result.stdout.includes("[ClawSec-Suite]")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Case-insensitive skill matching failed: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Empty suppressions array works (no suppressions applied)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -720,6 +776,7 @@ async function runAllTests() {
|
||||
await testMultipleSuppressions();
|
||||
await testSkillNameExtractionFromPath();
|
||||
await testSkillNameExtractionFromTitle();
|
||||
await testSkillNameMatchingIsCaseInsensitive();
|
||||
await testEmptySuppressions();
|
||||
await testConfigWithoutEnableFlagDoesNotSuppress();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTempDir, pass, fail, report, exitWithResults } from "../../clawsec-suite/test/lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "setup_cron.mjs");
|
||||
const NODE_BIN = process.execPath;
|
||||
|
||||
async function writeExecutable(filePath, content) {
|
||||
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function createFixture() {
|
||||
const tmp = await createTempDir();
|
||||
const binDir = path.join(tmp.path, "bin");
|
||||
const installDir = path.join(tmp.path, "install");
|
||||
const scriptsDir = path.join(installDir, "scripts");
|
||||
const capturePath = path.join(tmp.path, "openclaw-args.json");
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(scriptsDir, { recursive: true });
|
||||
await writeExecutable(path.join(scriptsDir, "runner.sh"), "#!/usr/bin/env bash\nexit 0\n");
|
||||
|
||||
await writeExecutable(
|
||||
path.join(binDir, "openclaw"),
|
||||
`#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const capturePath = process.env.OPENCLAW_CAPTURE_PATH;
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(args), "utf8");
|
||||
}
|
||||
|
||||
if (args[0] === "cron" && args[1] === "list") {
|
||||
process.stdout.write(JSON.stringify({ jobs: [] }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "cron" && args[1] === "add") {
|
||||
process.stdout.write(JSON.stringify({ id: "job-123" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "cron" && args[1] === "edit") {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write("unexpected args: " + JSON.stringify(args) + "\\n");
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
binDir,
|
||||
installDir,
|
||||
capturePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSetupCron(extraEnv = {}) {
|
||||
const fixture = await createFixture();
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
PATH: `${fixture.binDir}:${process.env.PATH || ""}`,
|
||||
OPENCLAW_CAPTURE_PATH: fixture.capturePath,
|
||||
PROMPTSEC_TZ: "UTC",
|
||||
PROMPTSEC_DM_CHANNEL: "telegram",
|
||||
PROMPTSEC_DM_TO: "@security-team",
|
||||
PROMPTSEC_INSTALL_DIR: fixture.installDir,
|
||||
};
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const proc = spawn(NODE_BIN, [SCRIPT_PATH], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", async (code) => {
|
||||
let capturedArgs = null;
|
||||
try {
|
||||
capturedArgs = JSON.parse(await fs.readFile(fixture.capturePath, "utf8"));
|
||||
} catch {}
|
||||
resolve({ code, stdout, stderr, capturedArgs, fixture });
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testPreflightSummaryIncludesDependenciesAndRecipients() {
|
||||
const testName = "setup_cron: preflight summary includes recipients and runtime review details";
|
||||
const result = await runSetupCron({
|
||||
PROMPTSEC_EMAIL_TO: "security@example.com",
|
||||
});
|
||||
|
||||
try {
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `setup_cron failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSummary = result.stdout.includes("Preflight review:");
|
||||
const hasDmTarget = result.stdout.includes("DM target: telegram:@security-team");
|
||||
const hasEmailTarget = result.stdout.includes("Email target: security@example.com");
|
||||
const hasDependencies = result.stdout.includes("Required runtime: openclaw CLI, node");
|
||||
|
||||
if (hasSummary && hasDmTarget && hasEmailTarget && hasDependencies) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Missing preflight detail in stdout: ${result.stdout}`);
|
||||
}
|
||||
} finally {
|
||||
await result.fixture.tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function testCronMessageDoesNotPromiseEmailWhenUnset() {
|
||||
const testName = "setup_cron: cron payload only promises email when email target is configured";
|
||||
const result = await runSetupCron();
|
||||
|
||||
try {
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `setup_cron failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageIndex = Array.isArray(result.capturedArgs) ? result.capturedArgs.indexOf("--message") : -1;
|
||||
const message = messageIndex >= 0 ? result.capturedArgs[messageIndex + 1] : "";
|
||||
|
||||
if (
|
||||
message.includes("Delivery DM: telegram:@security-team") &&
|
||||
message.includes("Email: disabled unless PROMPTSEC_EMAIL_TO is set") &&
|
||||
!message.includes("target@example.com")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Cron payload should keep email disabled by default: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
await result.fixture.tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
await testPreflightSummaryIncludesDependenciesAndRecipients();
|
||||
await testCronMessageDoesNotPromiseEmailWhenUnset();
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
console.error("Test runner failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to soul-guardian will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.5] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Regression coverage for launchd label migration so the installer documents and cleans up the previous Clawdbot-era label before starting the new default label.
|
||||
|
||||
### Changed
|
||||
|
||||
- `scripts/install_launchd_plist.py` now documents the legacy launchd label/plist in dry-run output and attempts a best-effort disable/bootout of `com.clawdbot.soul-guardian.<agentId>` before installing `com.openclaw.soul-guardian.<agentId>`.
|
||||
- The `--label` help now explains that non-legacy labels trigger legacy-job cleanup, while explicitly selecting the legacy label skips that migration path.
|
||||
|
||||
### Security
|
||||
|
||||
- Reduced the chance of duplicate launchd jobs or split monitoring state by making the old-label cleanup path explicit and warning the operator when manual launchd cleanup is still required.
|
||||
|
||||
## [0.0.4] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Regression coverage for launchd state-directory selection so existing legacy installs keep using their current guardian state unless the operator explicitly chooses a new location.
|
||||
|
||||
### Changed
|
||||
|
||||
- `scripts/install_launchd_plist.py` now reuses `~/.clawdbot/soul-guardian/<agentId>/` when that legacy state directory already exists and otherwise keeps the new `~/.openclaw/...` default.
|
||||
- The launchd installer now prints an explicit migration warning with the `--state-dir` value to use when switching an existing install to the new OpenClaw path.
|
||||
|
||||
### Security
|
||||
|
||||
- Prevented silent state-directory drift for existing launchd-based installs that would otherwise create a second guardian state tree and lose visibility into the approved baselines they were already enforcing.
|
||||
|
||||
## [0.0.3] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes that describe restore behavior, state-directory sensitivity, and optional scheduling integrations.
|
||||
- Metadata for persistence, network posture, and operator review expectations.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared optional integration runtimes used by the documented workflows (`openclaw`, `launchctl`, `bash`) alongside the required `python3` runtime.
|
||||
- Normalized the documented product/runtime naming to OpenClaw, including cron examples, default external state paths, and launchd labels.
|
||||
|
||||
### Security
|
||||
|
||||
- Made it explicit that restore mode can overwrite protected files back to baseline and that guardian state directories may contain sensitive snapshots, diffs, and quarantined content.
|
||||
@@ -1,12 +1,20 @@
|
||||
# soul-guardian
|
||||
|
||||
A small, dependency-free integrity guard for Clawdbot agent workspaces.
|
||||
A small, dependency-free integrity guard for OpenClaw agent workspaces.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `python3`
|
||||
- Optional runtime: `openclaw` for cron integration, `launchctl` for macOS scheduling
|
||||
- Side effects: can restore protected files to approved baselines and stores sensitive snapshots/audit data in the guardian state directory
|
||||
- Network behavior: none by default
|
||||
- Any cron/launchd scheduling is opt-in and should be reviewed before enabling
|
||||
|
||||
It helps you detect (and optionally auto-undo) unexpected edits to the workspace markdown files that an agent auto-loads (e.g., `SOUL.md`, `AGENTS.md`). It also records a **tamper-evident** audit trail of changes.
|
||||
|
||||
## Why this exists
|
||||
|
||||
In many Clawdbot setups, the agent reads certain markdown files every session (identity, instructions, memory, tools, etc.). If those files drift unexpectedly (accidental edits, bad merges, unwanted automation, etc.), you want:
|
||||
In many OpenClaw setups, the agent reads certain markdown files every session (identity, instructions, memory, tools, etc.). If those files drift unexpectedly (accidental edits, bad merges, unwanted automation, etc.), you want:
|
||||
|
||||
- detection (sha256 mismatch)
|
||||
- a diff/patch artifact for review
|
||||
@@ -72,7 +80,7 @@ python3 skills/soul-guardian/scripts/onboard_state_dir.py --agent-id <agentId>
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
init --actor sam --note "first baseline"
|
||||
```
|
||||
|
||||
@@ -80,7 +88,7 @@ python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
check --actor system --note "first check"
|
||||
```
|
||||
|
||||
@@ -90,7 +98,7 @@ Status (summary):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
status
|
||||
```
|
||||
|
||||
@@ -98,7 +106,7 @@ Check for drift (default: restores restore-mode files):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
check --actor system --note cron
|
||||
```
|
||||
|
||||
@@ -106,7 +114,7 @@ Alert-only check (never restore):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
check --no-restore
|
||||
```
|
||||
|
||||
@@ -114,7 +122,7 @@ Approve intentional edits (one file):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
approve --file SOUL.md --actor sam --note "intentional update"
|
||||
```
|
||||
|
||||
@@ -122,7 +130,7 @@ Approve all policy targets (except ignored ones):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
approve --all --actor sam --note "bulk approve"
|
||||
```
|
||||
|
||||
@@ -130,7 +138,7 @@ Restore (only restore-mode files):
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
restore --file SOUL.md --actor system --note "manual restore"
|
||||
```
|
||||
|
||||
@@ -138,7 +146,7 @@ Verify audit log tamper-evidence:
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
verify-audit
|
||||
```
|
||||
|
||||
@@ -173,7 +181,7 @@ python3 skills/soul-guardian/scripts/onboard_state_dir.py
|
||||
```
|
||||
|
||||
It will:
|
||||
- create an external state dir (**recommended default:** `~/.clawdbot/soul-guardian/<agentId>/`)
|
||||
- create an external state dir (**recommended default:** `~/.openclaw/soul-guardian/<agentId>/`)
|
||||
- copy (or move with `--move`) existing state from `memory/soul-guardian/`
|
||||
- write a default `policy.json` if missing
|
||||
- print scheduling snippets
|
||||
@@ -186,35 +194,35 @@ Notes:
|
||||
Then include `--state-dir` in all commands (run from the workspace root), e.g.:
|
||||
|
||||
```bash
|
||||
cd <workspace> && python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/<agentId> check
|
||||
cd <workspace> && python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.openclaw/soul-guardian/<agentId> check
|
||||
```
|
||||
|
||||
## Scheduling (cron)
|
||||
|
||||
### A) Clawdbot Gateway Cron (recommended)
|
||||
### A) OpenClaw Cron (recommended)
|
||||
|
||||
This is the default pattern when you want drift notifications to flow through Clawdbot.
|
||||
This is the default pattern when you want drift notifications to flow through OpenClaw.
|
||||
|
||||
Note: even when there is **no drift**, Clawdbot cron runs typically show an **OK summary** in the main session.
|
||||
Note: even when there is **no drift**, OpenClaw cron runs typically show an **OK summary** in the main session.
|
||||
|
||||
Example (edit paths + schedule):
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
openclaw cron add \
|
||||
--name "soul-guardian: check workspace" \
|
||||
--description "Run soul-guardian check; alert when drift detected." \
|
||||
--session isolated \
|
||||
--wake now \
|
||||
--cron "*/10 * * * *" \
|
||||
--tz UTC \
|
||||
--message "Run:\ncd '<workspace>'\npython3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/<agentId> check --actor cron --note 'gateway-cron'\n\nIf the command prints a line starting with 'SOUL_GUARDIAN_DRIFT', treat it as an alert. If it prints nothing, reply HEARTBEAT_OK." \
|
||||
--message "Run:\ncd '<workspace>'\npython3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.openclaw/soul-guardian/<agentId> check --actor cron --note 'gateway-cron'\n\nIf the command prints a line starting with 'SOUL_GUARDIAN_DRIFT', treat it as an alert. If it prints nothing, reply HEARTBEAT_OK." \
|
||||
--post-prefix "[soul-guardian]" \
|
||||
--post-mode summary
|
||||
```
|
||||
|
||||
### B) macOS launchd (optional, silent-on-OK)
|
||||
|
||||
If you want **system scheduling** without Clawdbot posting OK summaries, use `launchd`.
|
||||
If you want **system scheduling** without OpenClaw posting OK summaries, use `launchd`.
|
||||
|
||||
Because `soul_guardian.py check` prints **nothing** on OK and prints a single-line `SOUL_GUARDIAN_DRIFT ...` summary on drift, this tends to be silent unless something changed.
|
||||
|
||||
@@ -222,7 +230,7 @@ Generate + (optionally) install a LaunchAgent plist (run from the workspace root
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/install_launchd_plist.py \
|
||||
--state-dir ~/.clawdbot/soul-guardian/<agentId> \
|
||||
--state-dir ~/.openclaw/soul-guardian/<agentId> \
|
||||
--interval-seconds 600 \
|
||||
--install
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: soul-guardian
|
||||
version: 0.0.2
|
||||
version: 0.0.5
|
||||
description: Drift detection + baseline integrity guard for agent workspace files with automatic alerting support
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"👻","category":"security"}}
|
||||
@@ -14,6 +14,14 @@ clawdis:
|
||||
|
||||
Protects your agent's core files (SOUL.md, AGENTS.md, etc.) from unauthorized changes with automatic detection, restoration, and **user alerting**.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `python3`
|
||||
- Optional runtime: `openclaw` for cron integration, `launchctl` for macOS scheduling, `bash` for the demo helper
|
||||
- Side effects: can auto-restore protected files to their approved baseline and writes audit/quarantine state locally
|
||||
- Network behavior: none by default
|
||||
- Trust model: any scheduling is opt-in, but restore mode intentionally overwrites drifted files
|
||||
|
||||
## Quick Start (3 Steps)
|
||||
|
||||
### Step 1: Initialize baselines
|
||||
|
||||
@@ -13,7 +13,7 @@ Instead it:
|
||||
- writes logs to the state dir (so drift output is preserved)
|
||||
- relies on you to wire notifications however you prefer
|
||||
|
||||
If you want Clawdbot-side delivery, use Clawdbot Gateway Cron.
|
||||
If you want OpenClaw-side delivery, use OpenClaw cron.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,16 +26,82 @@ import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
LEGACY_STATE_ROOT = Path("~/.clawdbot/soul-guardian").expanduser()
|
||||
DEFAULT_STATE_ROOT = Path("~/.openclaw/soul-guardian").expanduser()
|
||||
LEGACY_LABEL_PREFIX = "com.clawdbot.soul-guardian."
|
||||
DEFAULT_LABEL_PREFIX = "com.openclaw.soul-guardian."
|
||||
|
||||
|
||||
def agent_id_default(workspace_root: Path) -> str:
|
||||
return workspace_root.name
|
||||
|
||||
|
||||
def default_external_state_dir(agent_id: str) -> Path:
|
||||
return Path("~/.clawdbot/soul-guardian").expanduser() / agent_id
|
||||
def legacy_label(agent_id: str) -> str:
|
||||
return f"{LEGACY_LABEL_PREFIX}{agent_id}"
|
||||
|
||||
|
||||
def run_launchctl(args: list[str]) -> None:
|
||||
subprocess.run(["/bin/launchctl", *args], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
def default_label(agent_id: str) -> str:
|
||||
return f"{DEFAULT_LABEL_PREFIX}{agent_id}"
|
||||
|
||||
|
||||
def legacy_plist_path(agent_id: str) -> Path:
|
||||
return Path("~/Library/LaunchAgents").expanduser() / f"{legacy_label(agent_id)}.plist"
|
||||
|
||||
|
||||
def default_external_state_dir(agent_id: str) -> tuple[Path, bool]:
|
||||
legacy_state_dir = LEGACY_STATE_ROOT / agent_id
|
||||
if legacy_state_dir.exists():
|
||||
return legacy_state_dir, True
|
||||
return DEFAULT_STATE_ROOT / agent_id, False
|
||||
|
||||
|
||||
def run_launchctl(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(["/bin/launchctl", *args], check=False, text=True, capture_output=True)
|
||||
|
||||
|
||||
def cleanup_legacy_launchd(uid: int, active_label: str, agent_id: str) -> list[str]:
|
||||
legacy_job_label = legacy_label(agent_id)
|
||||
legacy_job_plist = legacy_plist_path(agent_id).expanduser().resolve()
|
||||
if active_label == legacy_job_label:
|
||||
return []
|
||||
|
||||
cleanup_commands: list[tuple[list[str], str]] = [
|
||||
(
|
||||
["disable", f"gui/{uid}/{legacy_job_label}"],
|
||||
f"launchctl disable gui/{uid}/{legacy_job_label}",
|
||||
),
|
||||
(
|
||||
["bootout", f"gui/{uid}/{legacy_job_label}"],
|
||||
f"launchctl bootout gui/{uid}/{legacy_job_label}",
|
||||
),
|
||||
]
|
||||
|
||||
if legacy_job_plist.exists():
|
||||
cleanup_commands.append(
|
||||
(
|
||||
["bootout", f"gui/{uid}", str(legacy_job_plist)],
|
||||
f"launchctl bootout gui/{uid} {legacy_job_plist}",
|
||||
)
|
||||
)
|
||||
|
||||
failed_commands: list[str] = []
|
||||
for args, display_cmd in cleanup_commands:
|
||||
cp = run_launchctl(args)
|
||||
if cp.returncode != 0 and legacy_job_plist.exists():
|
||||
failed_commands.append(display_cmd)
|
||||
|
||||
if not failed_commands:
|
||||
return []
|
||||
|
||||
warning_lines = [
|
||||
"WARNING: Failed to fully clean up the legacy soul-guardian launchd job "
|
||||
f"{legacy_job_label}.",
|
||||
f"Manually run: launchctl bootout gui/{uid} {legacy_job_label}",
|
||||
]
|
||||
if legacy_job_plist.exists():
|
||||
warning_lines.append(f"If needed, also remove the legacy plist: {legacy_job_plist}")
|
||||
warning_lines.append("You can rerun this installer after the legacy job is removed.")
|
||||
return warning_lines
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
@@ -53,12 +119,12 @@ def main(argv: list[str]) -> int:
|
||||
ap.add_argument(
|
||||
"--state-dir",
|
||||
default=None,
|
||||
help="External state directory (recommended). Default: ~/.clawdbot/soul-guardian/<agentId>/",
|
||||
help="External state directory (recommended). Default: ~/.openclaw/soul-guardian/<agentId>/; reuses ~/.clawdbot/soul-guardian/<agentId>/ if that legacy state dir already exists.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--label",
|
||||
default=None,
|
||||
help="launchd label (default: com.clawdbot.soul-guardian.<agentId>)",
|
||||
help="launchd label (default: com.openclaw.soul-guardian.<agentId>). When using a non-legacy label, --install attempts to disable/boot out the previous com.clawdbot.soul-guardian.<agentId> job first.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--interval-seconds",
|
||||
@@ -84,9 +150,24 @@ def main(argv: list[str]) -> int:
|
||||
|
||||
workspace_root = Path(args.workspace_root).expanduser().resolve()
|
||||
agent_id = args.agent_id or agent_id_default(workspace_root)
|
||||
state_dir = Path(args.state_dir).expanduser().resolve() if args.state_dir else default_external_state_dir(agent_id)
|
||||
if args.state_dir:
|
||||
state_dir = Path(args.state_dir).expanduser().resolve()
|
||||
else:
|
||||
state_dir, using_legacy_state_dir = default_external_state_dir(agent_id)
|
||||
state_dir = state_dir.resolve()
|
||||
if using_legacy_state_dir:
|
||||
migration_target = (DEFAULT_STATE_ROOT / agent_id).resolve()
|
||||
print(
|
||||
"WARNING: Detected legacy soul-guardian state dir at "
|
||||
f"{state_dir}. Using it for backward compatibility. "
|
||||
"To switch to the new default location, rerun this script with "
|
||||
f"--state-dir {migration_target}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
label = args.label or f"com.clawdbot.soul-guardian.{agent_id}"
|
||||
label = args.label or default_label(agent_id)
|
||||
legacy_job_label = legacy_label(agent_id)
|
||||
legacy_job_plist = legacy_plist_path(agent_id).expanduser().resolve()
|
||||
plist_path = Path(args.out).expanduser().resolve() if args.out else (Path("~/Library/LaunchAgents").expanduser() / f"{label}.plist")
|
||||
|
||||
script_path = workspace_root / "skills" / "soul-guardian" / "scripts" / "soul_guardian.py"
|
||||
@@ -134,10 +215,22 @@ def main(argv: list[str]) -> int:
|
||||
print(f"Wrote plist: {plist_path}")
|
||||
print(f"State dir: {state_dir}")
|
||||
print(f"Label: {label}")
|
||||
if label == legacy_job_label:
|
||||
print("Legacy label mode: cleanup is skipped because the selected label matches the previous Clawdbot-era default.")
|
||||
else:
|
||||
print(f"Legacy label: {legacy_job_label}")
|
||||
print(f"Legacy plist: {legacy_job_plist}")
|
||||
if args.install:
|
||||
print("Migration: install mode will try to disable/boot out the legacy launchd job before starting the new label.")
|
||||
else:
|
||||
print("Dry run: --install will try to disable/boot out the legacy launchd job before starting the new label.")
|
||||
|
||||
uid = os.getuid()
|
||||
|
||||
if args.install:
|
||||
for warning_line in cleanup_legacy_launchd(uid, label, agent_id):
|
||||
print(warning_line, file=sys.stderr)
|
||||
|
||||
# Best-effort: remove any existing job with same label, then bootstrap.
|
||||
run_launchctl(["bootout", f"gui/{uid}", label])
|
||||
run_launchctl(["bootout", f"gui/{uid}", str(plist_path)])
|
||||
|
||||
@@ -6,10 +6,10 @@ Why:
|
||||
- Moving state to an external directory improves resilience and makes tampering harder.
|
||||
|
||||
What this script does:
|
||||
- Creates an external state directory (default: ~/.clawdbot/soul-guardian/<agentId>/)
|
||||
- Creates an external state directory (default: ~/.openclaw/soul-guardian/<agentId>/)
|
||||
- Copies (or moves) existing in-workspace state from memory/soul-guardian/
|
||||
- Writes a default policy.json if missing
|
||||
- Prints recommended cron snippets (Clawdbot gateway cron and optional launchd)
|
||||
- Prints recommended cron snippets (OpenClaw cron and optional launchd)
|
||||
|
||||
This script does NOT modify your cron jobs automatically.
|
||||
"""
|
||||
@@ -76,7 +76,7 @@ def main(argv: list[str]) -> int:
|
||||
ap.add_argument(
|
||||
"--state-dir",
|
||||
default=None,
|
||||
help="External state directory to create/use (default: ~/.clawdbot/soul-guardian/<agentId>/).",
|
||||
help="External state directory to create/use (default: ~/.openclaw/soul-guardian/<agentId>/).",
|
||||
)
|
||||
ap.add_argument("--move", action="store_true", help="Move instead of copy (WARNING: deletes the old in-workspace state dir).")
|
||||
ap.add_argument("--no-copy", action="store_true", help="Do not copy/move existing in-workspace state.")
|
||||
@@ -85,7 +85,7 @@ def main(argv: list[str]) -> int:
|
||||
if args.state_dir:
|
||||
external = Path(args.state_dir).expanduser()
|
||||
else:
|
||||
external = (Path("~/.clawdbot/soul-guardian").expanduser() / args.agent_id)
|
||||
external = (Path("~/.openclaw/soul-guardian").expanduser() / args.agent_id)
|
||||
|
||||
ensure_dir(external)
|
||||
|
||||
@@ -117,14 +117,14 @@ def main(argv: list[str]) -> int:
|
||||
)
|
||||
|
||||
print("2) Update your cron/check runner to include --state-dir.")
|
||||
print("\nClawdbot gateway cron (recommended; does not require system cron):")
|
||||
print("\nOpenClaw cron (recommended; does not require system cron):")
|
||||
print("- In your cron spec, run something like:")
|
||||
print(
|
||||
f" cd '{WORKSPACE_ROOT}' && python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir '{external}' check --actor system --note cron"
|
||||
)
|
||||
|
||||
print("\nOptional: system cron / launchd (macOS) example (NOT installed automatically):")
|
||||
label = f"com.clawdbot.soul-guardian.{args.agent_id}"
|
||||
label = f"com.openclaw.soul-guardian.{args.agent_id}"
|
||||
print(f"- Launchd label: {label}")
|
||||
print(f"- WorkingDirectory (recommended): {WORKSPACE_ROOT}")
|
||||
print("- ProgramArguments (example):")
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for install_launchd_plist.py default state-dir selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = REPO_ROOT / "skills" / "soul-guardian" / "scripts" / "install_launchd_plist.py"
|
||||
|
||||
|
||||
def run(cmd: list[str], env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, text=True, capture_output=True, env=env)
|
||||
|
||||
|
||||
def must_ok(cp: subprocess.CompletedProcess) -> None:
|
||||
if cp.returncode != 0:
|
||||
raise AssertionError(f"Expected rc=0, got {cp.returncode}\nSTDOUT:\n{cp.stdout}\nSTDERR:\n{cp.stderr}")
|
||||
|
||||
|
||||
def load_program_arguments(plist_path: Path) -> list[str]:
|
||||
with plist_path.open("rb") as handle:
|
||||
return plistlib.load(handle)["ProgramArguments"]
|
||||
|
||||
|
||||
def run_case(home_dir: Path, agent_id: str) -> subprocess.CompletedProcess:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home_dir)
|
||||
plist_path = home_dir / "LaunchAgents" / f"{agent_id}.plist"
|
||||
cmd = [
|
||||
"python3",
|
||||
str(SCRIPT),
|
||||
"--workspace-root",
|
||||
str(REPO_ROOT),
|
||||
"--agent-id",
|
||||
agent_id,
|
||||
"--out",
|
||||
str(plist_path),
|
||||
"--force",
|
||||
]
|
||||
return run(cmd, env)
|
||||
|
||||
|
||||
def assert_contains(text: str, expected: str, label: str) -> None:
|
||||
if expected not in text:
|
||||
raise AssertionError(f"Missing {label}: expected to find {expected!r}\nActual text:\n{text}")
|
||||
|
||||
|
||||
def load_module(home_dir: Path) -> ModuleType:
|
||||
previous_home = os.environ.get("HOME")
|
||||
os.environ["HOME"] = str(home_dir)
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("test_install_launchd_plist_module", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise AssertionError("Failed to load install_launchd_plist.py for testing")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
if previous_home is None:
|
||||
os.environ.pop("HOME", None)
|
||||
else:
|
||||
os.environ["HOME"] = previous_home
|
||||
|
||||
|
||||
def call_main_with_home(module: ModuleType, home_dir: Path, argv: list[str]) -> int:
|
||||
previous_home = os.environ.get("HOME")
|
||||
os.environ["HOME"] = str(home_dir)
|
||||
try:
|
||||
return module.main(argv)
|
||||
finally:
|
||||
if previous_home is None:
|
||||
os.environ.pop("HOME", None)
|
||||
else:
|
||||
os.environ["HOME"] = previous_home
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home_dir = Path(td)
|
||||
agent_id = "legacy-agent"
|
||||
legacy_state_dir = home_dir / ".clawdbot" / "soul-guardian" / agent_id
|
||||
legacy_state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cp = run_case(home_dir, agent_id)
|
||||
must_ok(cp)
|
||||
|
||||
legacy_state_suffix = "/.clawdbot/soul-guardian/legacy-agent"
|
||||
new_state_suffix = "/.openclaw/soul-guardian/legacy-agent"
|
||||
assert_contains(cp.stdout, legacy_state_suffix, "legacy state dir in stdout")
|
||||
assert_contains(cp.stderr, legacy_state_suffix, "legacy state dir warning")
|
||||
assert_contains(cp.stderr, new_state_suffix, "migration target warning")
|
||||
|
||||
program_args = load_program_arguments(home_dir / "LaunchAgents" / f"{agent_id}.plist")
|
||||
if not any(arg.endswith(legacy_state_suffix) for arg in program_args):
|
||||
raise AssertionError(f"Expected plist to reference legacy state dir.\nProgramArguments: {program_args}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home_dir = Path(td)
|
||||
agent_id = "fresh-agent"
|
||||
|
||||
cp = run_case(home_dir, agent_id)
|
||||
must_ok(cp)
|
||||
|
||||
new_state_suffix = "/.openclaw/soul-guardian/fresh-agent"
|
||||
assert_contains(cp.stdout, new_state_suffix, "new state dir in stdout")
|
||||
if cp.stderr.strip():
|
||||
raise AssertionError(f"Did not expect migration warning for fresh install.\nSTDERR:\n{cp.stderr}")
|
||||
|
||||
program_args = load_program_arguments(home_dir / "LaunchAgents" / f"{agent_id}.plist")
|
||||
if not any(arg.endswith(new_state_suffix) for arg in program_args):
|
||||
raise AssertionError(f"Expected plist to reference new state dir.\nProgramArguments: {program_args}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home_dir = Path(td)
|
||||
agent_id = "migrate-agent"
|
||||
legacy_label = f"com.clawdbot.soul-guardian.{agent_id}"
|
||||
legacy_plist = home_dir / "Library" / "LaunchAgents" / f"{legacy_label}.plist"
|
||||
legacy_plist.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_plist.write_text("legacy", encoding="utf-8")
|
||||
|
||||
cp = run(
|
||||
[
|
||||
"python3",
|
||||
str(SCRIPT),
|
||||
"--workspace-root",
|
||||
str(REPO_ROOT),
|
||||
"--agent-id",
|
||||
agent_id,
|
||||
"--force",
|
||||
],
|
||||
{**os.environ, "HOME": str(home_dir)},
|
||||
)
|
||||
must_ok(cp)
|
||||
assert_contains(cp.stdout, legacy_label, "legacy label dry-run note")
|
||||
|
||||
module = load_module(home_dir)
|
||||
launchctl_calls: list[list[str]] = []
|
||||
subprocess_calls: list[list[str]] = []
|
||||
|
||||
def fake_run_launchctl(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
launchctl_calls.append(args)
|
||||
return subprocess.CompletedProcess(["/bin/launchctl", *args], 0, "", "")
|
||||
|
||||
def fake_subprocess_run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
subprocess_calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
|
||||
module.run_launchctl = fake_run_launchctl
|
||||
module.subprocess.run = fake_subprocess_run
|
||||
module.os.getuid = lambda: 501
|
||||
|
||||
stdout_buffer = io.StringIO()
|
||||
stderr_buffer = io.StringIO()
|
||||
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
|
||||
rc = call_main_with_home(
|
||||
module,
|
||||
home_dir,
|
||||
[
|
||||
"--workspace-root",
|
||||
str(REPO_ROOT),
|
||||
"--agent-id",
|
||||
agent_id,
|
||||
"--force",
|
||||
"--install",
|
||||
],
|
||||
)
|
||||
if rc != 0:
|
||||
raise AssertionError(f"Expected install flow rc=0, got {rc}")
|
||||
|
||||
expected_prefix = [
|
||||
["disable", "gui/501/com.clawdbot.soul-guardian.migrate-agent"],
|
||||
["bootout", "gui/501/com.clawdbot.soul-guardian.migrate-agent"],
|
||||
["bootout", "gui/501", str(legacy_plist.resolve())],
|
||||
]
|
||||
if launchctl_calls[:3] != expected_prefix:
|
||||
raise AssertionError(f"Expected legacy cleanup calls first.\nActual launchctl calls: {launchctl_calls}")
|
||||
|
||||
if ["/bin/launchctl", "enable", "gui/501/com.openclaw.soul-guardian.migrate-agent"] not in subprocess_calls:
|
||||
raise AssertionError(f"Expected enable call for new label.\nSubprocess calls: {subprocess_calls}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home_dir = Path(td)
|
||||
agent_id = "warn-agent"
|
||||
legacy_label = f"com.clawdbot.soul-guardian.{agent_id}"
|
||||
legacy_plist = home_dir / "Library" / "LaunchAgents" / f"{legacy_label}.plist"
|
||||
legacy_plist.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_plist.write_text("legacy", encoding="utf-8")
|
||||
|
||||
module = load_module(home_dir)
|
||||
|
||||
def fake_run_launchctl_warn(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(["/bin/launchctl", *args], 1, "", "cleanup failed")
|
||||
|
||||
def fake_subprocess_run_warn(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if args[:2] == ["/bin/launchctl", "bootstrap"]:
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
if args[:2] == ["/bin/launchctl", "enable"]:
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
if args[:2] == ["/bin/launchctl", "kickstart"]:
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
return subprocess.CompletedProcess(args, 1, "", "cleanup failed")
|
||||
|
||||
module.run_launchctl = fake_run_launchctl_warn
|
||||
module.subprocess.run = fake_subprocess_run_warn
|
||||
module.os.getuid = lambda: 501
|
||||
|
||||
stdout_buffer = io.StringIO()
|
||||
stderr_buffer = io.StringIO()
|
||||
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
|
||||
rc = call_main_with_home(
|
||||
module,
|
||||
home_dir,
|
||||
[
|
||||
"--workspace-root",
|
||||
str(REPO_ROOT),
|
||||
"--agent-id",
|
||||
agent_id,
|
||||
"--force",
|
||||
"--install",
|
||||
],
|
||||
)
|
||||
if rc != 0:
|
||||
raise AssertionError(f"Expected install flow rc=0 with cleanup warning, got {rc}")
|
||||
assert_contains(stderr_buffer.getvalue(), "launchctl bootout gui/501 com.clawdbot.soul-guardian.warn-agent", "manual cleanup warning")
|
||||
assert_contains(stderr_buffer.getvalue(), str(legacy_plist.resolve()), "legacy plist warning")
|
||||
|
||||
print("OK: install_launchd_plist default state-dir tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soul-guardian",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.5",
|
||||
"description": "Drift detection and baseline integrity guard for agent workspace prompt files. Auto-restore critical files with tamper-evident audit logging.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -22,6 +22,11 @@
|
||||
"required": true,
|
||||
"description": "Soul guardian skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "scripts/soul_guardian.py",
|
||||
"required": true,
|
||||
@@ -47,6 +52,24 @@
|
||||
"python3"
|
||||
]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [],
|
||||
"optional_bins": [
|
||||
"openclaw",
|
||||
"launchctl",
|
||||
"bash"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No automation is installed by default, but the documented workflow supports heartbeat, OpenClaw cron, or launchd scheduling.",
|
||||
"network_egress": "None by default; soul-guardian operates on local files and local state."
|
||||
},
|
||||
"operator_review": [
|
||||
"Restore mode can overwrite protected workspace files back to their approved baseline.",
|
||||
"The external state directory can contain sensitive snapshots, diffs, and quarantined copies; secure it with restrictive permissions.",
|
||||
"Any launchd or cron scheduling is opt-in and should be reviewed before enabling."
|
||||
],
|
||||
"triggers": [
|
||||
"soul guardian",
|
||||
"integrity check",
|
||||
|
||||
Reference in New Issue
Block a user