diff --git a/skills/claw-release/CHANGELOG.md b/skills/claw-release/CHANGELOG.md new file mode 100644 index 0000000..b545a6c --- /dev/null +++ b/skills/claw-release/CHANGELOG.md @@ -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. diff --git a/skills/claw-release/SKILL.md b/skills/claw-release/SKILL.md index 0045681..743fc0c 100644 --- a/skills/claw-release/SKILL.md +++ b/skills/claw-release/SKILL.md @@ -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 -v +git tag -d -v +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 diff --git a/skills/claw-release/skill.json b/skills/claw-release/skill.json index 9ca40f1..7315d1c 100644 --- a/skills/claw-release/skill.json +++ b/skills/claw-release/skill.json @@ -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", diff --git a/skills/clawsec-clawhub-checker/CHANGELOG.md b/skills/clawsec-clawhub-checker/CHANGELOG.md new file mode 100644 index 0000000..c72bf58 --- /dev/null +++ b/skills/clawsec-clawhub-checker/CHANGELOG.md @@ -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. diff --git a/skills/clawsec-clawhub-checker/README.md b/skills/clawsec-clawhub-checker/README.md index aedcc14..29272a7 100644 --- a/skills/clawsec-clawhub-checker/README.md +++ b/skills/clawsec-clawhub-checker/README.md @@ -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) diff --git a/skills/clawsec-clawhub-checker/SKILL.md b/skills/clawsec-clawhub-checker/SKILL.md index a208eb1..b337f67 100644 --- a/skills/clawsec-clawhub-checker/SKILL.md +++ b/skills/clawsec-clawhub-checker/SKILL.md @@ -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 diff --git a/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs b/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs index cd70019..acdea69 100644 --- a/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs +++ b/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs @@ -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 diff --git a/skills/clawsec-clawhub-checker/skill.json b/skills/clawsec-clawhub-checker/skill.json index b098832..35ac510 100644 --- a/skills/clawsec-clawhub-checker/skill.json +++ b/skills/clawsec-clawhub-checker/skill.json @@ -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", diff --git a/skills/clawsec-clawhub-checker/test/setup_reputation_hook.test.mjs b/skills/clawsec-clawhub-checker/test/setup_reputation_hook.test.mjs new file mode 100644 index 0000000..0ead839 --- /dev/null +++ b/skills/clawsec-clawhub-checker/test/setup_reputation_hook.test.mjs @@ -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); +}); diff --git a/skills/clawsec-feed/CHANGELOG.md b/skills/clawsec-feed/CHANGELOG.md index a3dfce1..f60b42a 100644 --- a/skills/clawsec-feed/CHANGELOG.md +++ b/skills/clawsec-feed/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-feed/README.md b/skills/clawsec-feed/README.md index 4f7b8e4..893c019 100644 --- a/skills/clawsec-feed/README.md +++ b/skills/clawsec-feed/README.md @@ -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 diff --git a/skills/clawsec-feed/SKILL.md b/skills/clawsec-feed/SKILL.md index 421f773..cf40ecc 100644 --- a/skills/clawsec-feed/SKILL.md +++ b/skills/clawsec-feed/SKILL.md @@ -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: diff --git a/skills/clawsec-feed/skill.json b/skills/clawsec-feed/skill.json index c6853a6..ce297d6 100644 --- a/skills/clawsec-feed/skill.json +++ b/skills/clawsec-feed/skill.json @@ -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", diff --git a/skills/clawsec-suite/CHANGELOG.md b/skills/clawsec-suite/CHANGELOG.md index 8d6ee10..24f1599 100644 --- a/skills/clawsec-suite/CHANGELOG.md +++ b/skills/clawsec-suite/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index 65548b6..7d1e4ad 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -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, diff --git a/skills/clawsec-suite/scripts/setup_advisory_cron.mjs b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs index b15032f..2fb5767 100644 --- a/skills/clawsec-suite/scripts/setup_advisory_cron.mjs +++ b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs @@ -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); diff --git a/skills/clawsec-suite/scripts/setup_advisory_hook.mjs b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs index 8a239fa..3e32d31 100644 --- a/skills/clawsec-suite/scripts/setup_advisory_hook.mjs +++ b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs @@ -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(); diff --git a/skills/clawsec-suite/skill.json b/skills/clawsec-suite/skill.json index 8a7386a..b510f1f 100644 --- a/skills/clawsec-suite/skill.json +++ b/skills/clawsec-suite/skill.json @@ -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", diff --git a/skills/clawsec-suite/test/setup_disclosure.test.mjs b/skills/clawsec-suite/test/setup_disclosure.test.mjs new file mode 100644 index 0000000..4dab273 --- /dev/null +++ b/skills/clawsec-suite/test/setup_disclosure.test.mjs @@ -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); +}); diff --git a/skills/clawtributor/CHANGELOG.md b/skills/clawtributor/CHANGELOG.md new file mode 100644 index 0000000..5d17813 --- /dev/null +++ b/skills/clawtributor/CHANGELOG.md @@ -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. diff --git a/skills/clawtributor/README.md b/skills/clawtributor/README.md index acea6c4..1f811b1 100644 --- a/skills/clawtributor/README.md +++ b/skills/clawtributor/README.md @@ -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 diff --git a/skills/clawtributor/SKILL.md b/skills/clawtributor/SKILL.md index a9d4bb3..f79d08b 100644 --- a/skills/clawtributor/SKILL.md +++ b/skills/clawtributor/SKILL.md @@ -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)** --- diff --git a/skills/clawtributor/skill.json b/skills/clawtributor/skill.json index 3894d3b..1ee719a 100644 --- a/skills/clawtributor/skill.json +++ b/skills/clawtributor/skill.json @@ -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", diff --git a/skills/openclaw-audit-watchdog/CHANGELOG.md b/skills/openclaw-audit-watchdog/CHANGELOG.md index c6f05eb..c36eca8 100644 --- a/skills/openclaw-audit-watchdog/CHANGELOG.md +++ b/skills/openclaw-audit-watchdog/CHANGELOG.md @@ -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 diff --git a/skills/openclaw-audit-watchdog/README.md b/skills/openclaw-audit-watchdog/README.md index 834539c..dcf0dd0 100644 --- a/skills/openclaw-audit-watchdog/README.md +++ b/skills/openclaw-audit-watchdog/README.md @@ -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@` | ### 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. diff --git a/skills/openclaw-audit-watchdog/SKILL.md b/skills/openclaw-audit-watchdog/SKILL.md index 949844e..a75547e 100644 --- a/skills/openclaw-audit-watchdog/SKILL.md +++ b/skills/openclaw-audit-watchdog/SKILL.md @@ -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=)` +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 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 diff --git a/skills/openclaw-audit-watchdog/scripts/render_report.mjs b/skills/openclaw-audit-watchdog/scripts/render_report.mjs index 7a7f22a..db0b539 100755 --- a/skills/openclaw-audit-watchdog/scripts/render_report.mjs +++ b/skills/openclaw-audit-watchdog/scripts/render_report.mjs @@ -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, diff --git a/skills/openclaw-audit-watchdog/scripts/runner.sh b/skills/openclaw-audit-watchdog/scripts/runner.sh index c24b374..082e98d 100755 --- a/skills/openclaw-audit-watchdog/scripts/runner.sh +++ b/skills/openclaw-audit-watchdog/scripts/runner.sh @@ -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 diff --git a/skills/openclaw-audit-watchdog/scripts/sendmail_report.sh b/skills/openclaw-audit-watchdog/scripts/sendmail_report.sh index f04f58c..4d0e95a 100755 --- a/skills/openclaw-audit-watchdog/scripts/sendmail_report.sh +++ b/skills/openclaw-audit-watchdog/scripts/sendmail_report.sh @@ -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" diff --git a/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs b/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs index 32a0c51..a31e579 100755 --- a/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs +++ b/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs @@ -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 = [ diff --git a/skills/openclaw-audit-watchdog/skill.json b/skills/openclaw-audit-watchdog/skill.json index 3350928..f726b87 100644 --- a/skills/openclaw-audit-watchdog/skill.json +++ b/skills/openclaw-audit-watchdog/skill.json @@ -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", diff --git a/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs b/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs index 7bc3aab..306b935 100755 --- a/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs +++ b/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs @@ -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 { diff --git a/skills/openclaw-audit-watchdog/test/setup_cron.test.mjs b/skills/openclaw-audit-watchdog/test/setup_cron.test.mjs new file mode 100644 index 0000000..c521cdf --- /dev/null +++ b/skills/openclaw-audit-watchdog/test/setup_cron.test.mjs @@ -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); +}); diff --git a/skills/soul-guardian/CHANGELOG.md b/skills/soul-guardian/CHANGELOG.md new file mode 100644 index 0000000..62cb9e0 --- /dev/null +++ b/skills/soul-guardian/CHANGELOG.md @@ -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.` before installing `com.openclaw.soul-guardian.`. +- 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//` 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. diff --git a/skills/soul-guardian/README.md b/skills/soul-guardian/README.md index 4b814a5..0d8cd9f 100644 --- a/skills/soul-guardian/README.md +++ b/skills/soul-guardian/README.md @@ -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 ```bash python3 skills/soul-guardian/scripts/soul_guardian.py \ - --state-dir ~/.clawdbot/soul-guardian/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ 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//`) +- create an external state dir (**recommended default:** `~/.openclaw/soul-guardian//`) - 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 && python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/ check +cd && python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.openclaw/soul-guardian/ 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 ''\npython3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/ 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 ''\npython3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.openclaw/soul-guardian/ 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/ \ + --state-dir ~/.openclaw/soul-guardian/ \ --interval-seconds 600 \ --install ``` diff --git a/skills/soul-guardian/SKILL.md b/skills/soul-guardian/SKILL.md index f5a8870..e12de2b 100644 --- a/skills/soul-guardian/SKILL.md +++ b/skills/soul-guardian/SKILL.md @@ -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 diff --git a/skills/soul-guardian/scripts/install_launchd_plist.py b/skills/soul-guardian/scripts/install_launchd_plist.py index 4b15850..82bbaf7 100644 --- a/skills/soul-guardian/scripts/install_launchd_plist.py +++ b/skills/soul-guardian/scripts/install_launchd_plist.py @@ -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//", + help="External state directory (recommended). Default: ~/.openclaw/soul-guardian//; reuses ~/.clawdbot/soul-guardian// if that legacy state dir already exists.", ) ap.add_argument( "--label", default=None, - help="launchd label (default: com.clawdbot.soul-guardian.)", + help="launchd label (default: com.openclaw.soul-guardian.). When using a non-legacy label, --install attempts to disable/boot out the previous com.clawdbot.soul-guardian. 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)]) diff --git a/skills/soul-guardian/scripts/onboard_state_dir.py b/skills/soul-guardian/scripts/onboard_state_dir.py index 6eba7dd..0263e11 100644 --- a/skills/soul-guardian/scripts/onboard_state_dir.py +++ b/skills/soul-guardian/scripts/onboard_state_dir.py @@ -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//) +- Creates an external state directory (default: ~/.openclaw/soul-guardian//) - 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//).", + help="External state directory to create/use (default: ~/.openclaw/soul-guardian//).", ) 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):") diff --git a/skills/soul-guardian/scripts/test_install_launchd_plist.py b/skills/soul-guardian/scripts/test_install_launchd_plist.py new file mode 100644 index 0000000..944676d --- /dev/null +++ b/skills/soul-guardian/scripts/test_install_launchd_plist.py @@ -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()) diff --git a/skills/soul-guardian/skill.json b/skills/soul-guardian/skill.json index 87adf68..ebd6e80 100644 --- a/skills/soul-guardian/skill.json +++ b/skills/soul-guardian/skill.json @@ -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",