diff --git a/README.md b/README.md index 1255572..565b04f 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,8 @@ input. Current runtime and manifest release: **0.2.0**. -**Docs:** the [StructuPath Herdr Plugins wiki](https://github.com/StructuPath/herdr-browser/wiki) -is the practical guide to this plugin and its three siblings (Browser, Swarm, -Conductor). +**Docs:** [Herdr Suite Guard guide](https://herdr.structupath.ai/docs/guard/), +[Guard specification](docs/SPEC.md), and [readiness and upgrade guide](docs/readiness.md). ![herdr-guard policy dry-run demo](assets/herdr-guard-demo.gif) @@ -123,8 +122,8 @@ tampering (`herdr plugin disable`, killing Herdr, deleting rules or audit files), and evasion indicators such as `stty -echo`, detached tmux/screen, `disown`/`setsid`, history clearing, and base64/hex-to-shell decoding. Every rule ships with hit and near-miss tests (`tests/rules-default.test.mjs`); -`git push --force-with-lease`, `id_rsa.pub` reads, and similar benign -neighbors are explicitly kept silent. Review the defaults before enabling +`git push --force-with-lease` is audit-tier, while `id_rsa.pub` reads stay +silent. Review the defaults before enabling interrupt rules in production. ## Security and trust @@ -145,13 +144,24 @@ fresh interrupt, and interrupt matches are intentionally never deduplicated. ## Development Requirements: Herdr 0.7.5+, Node.js 20.10+ (JSON import attributes), and the platform lock utility (`lockf` on macOS or `flock` on Linux). +Source validation also requires Python 3.11+ (`tomllib`) and Bash. Herdr's +manifest build currently runs the test suite, including manifest validation, +so Python is required when installing from source as well. ```sh -npm test +npm run doctor +npm run validate herdr plugin link . herdr plugin list ``` +`npm run doctor` checks prerequisites without opening panes or changing policy. +If PATH selects an older Herdr, select the intended binary explicitly: +`HERDR_BIN_PATH=/absolute/path/to/herdr npm run doctor`. Use that same binary +to start Herdr and link the plugin. `npm run build` checks JavaScript and shell +syntax plus manifest entrypoints and release consistency; plain ESM needs no +compilation. `npm run validate` runs that build and the full test suite. + Tests use a fake NDJSON socket and temporary config/audit directories; they do not open panes or invoke live actions. The implementation uses plain ESM Node with no runtime dependencies. diff --git a/docs/readiness.md b/docs/readiness.md new file mode 100644 index 0000000..54370b8 --- /dev/null +++ b/docs/readiness.md @@ -0,0 +1,67 @@ +# Readiness and upgrades + +Guard's runtime and manifest remain **0.2.0**. The readiness tooling does not +change the shipped rules, severity mapping, hook installation, or enforcement +behavior. The implementation uses plain Node ESM without runtime dependencies. + +## Build and validation + +Run `npm run doctor`, then `npm run validate` before linking the plugin. +Doctor checks Node, the platform lock utility, Python's TOML parser, and the +selected Herdr binary. It does not connect to a session or inspect private +configuration. A successful doctor check proves prerequisites only, not live +pane or harness operation. + +Minimums are Node 20.10, Python 3.11, and Herdr 0.7.5. macOS needs `lockf`; +Linux needs `flock`. Bash is used by plugin entrypoints and syntax validation. +If several Herdr binaries are installed, +`HERDR_BIN_PATH=/absolute/path/to/herdr npm run doctor` checks the selected executable. +Start Herdr and link the plugin using that same executable. + +The full validation checks every JavaScript and shell file, manifest commands, +release consistency, and tests using isolated temporary files and local Unix +sockets. The reporter tests run the actual Claude Code hook process against a +local reporter server. They verify decision JSON; they do not prove that a +particular installed Claude Code version honors the decision. Dangerous test +commands are strings and are never executed. + +Validation recorded on 2026-09-13: 132 tests passed on Node 20.20.2, 24.18.0, +and 26.4.0 on macOS. Build checked 27 JavaScript/shell files and 10 manifest +entrypoints. Doctor accepted the installed Herdr 0.8.2 and rejected 0.7.1 +against the declared 0.7.5 minimum. These checks do not establish live Herdr +0.8.2 integration coverage. + +## Upgrading from 0.1.1 + +0.2.0 adds the harness reporter and expands the default policy from 26 to 52 +rules. Existing `rules.json` files are preserved. Upgrading the plugin does +not automatically add the new defaults to a customized policy. Compare your +configuration with `src/rules-default.json` before choosing which new rules +to adopt. The `structupath.guard.reset-rules` action replaces the current rules +with defaults and creates a timestamped backup; use it only when that reset +is intended. The reset audit records old and new rule counts. + +The reporter rendezvous is `$XDG_STATE_HOME/herdr-guard/reporter.sock`, falling +back to `~/.local/state/herdr-guard/reporter.sock`. It is separate from +`HERDR_PLUGIN_STATE_DIR`. To override it, give the Guard process and the harness +hook the same nonempty `HERDR_GUARD_REPORTER_SOCKET`. Only one Guard process +serves a given reporter socket; another session leaves a live owner alone. + +The bundled hook must be configured explicitly in the harness. Its responses +are advisory verdicts: interrupt-tier becomes `deny`, alert-tier becomes +`warn`, audit or unmatched becomes `allow`. The Claude Code adapter emits +`permissionDecision: "deny"` or `"ask"`; an unavailable or malformed reporter +response fails open. Paused enforcement still audits and returns `allow`. +Neither this protocol nor pane interrupts prove prevention. + +## Next improvements + +1. Repeat isolated live-session smoke tests on each supported Herdr release, + including pane restart, duplicate startup, reconnect, and named sessions. +2. Verify the opt-in Claude Code hook against the installed harness version + using harmless fixture commands before relying on its decisions. +3. Add a policy migration preview that compares existing rules with new + defaults while preserving user edits; keep applying changes explicit. +4. Add additional harness adapters only with protocol and lifecycle tests. + Popup visibility and per-plugin socket permissions require upstream Herdr + support and remain limitations. diff --git a/package.json b/package.json index b7786b6..3529db2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "node": ">=20.10" }, "scripts": { - "test": "node --test tests/*.test.mjs" + "build": "node scripts/check-syntax.mjs && node scripts/check-manifest.mjs", + "test": "node --test tests/*.test.mjs", + "validate": "npm run build && npm test", + "doctor": "node scripts/doctor.mjs" }, "repository": { "type": "git", diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs index 15a656c..7ef9f74 100755 --- a/scripts/check-manifest.mjs +++ b/scripts/check-manifest.mjs @@ -13,7 +13,7 @@ with open(sys.argv[1], "rb") as manifest: json.dump(tomllib.load(manifest), sys.stdout) `; -function parseManifest(manifestPath) { +export function parseManifest(manifestPath) { const result = spawnSync("python3", ["-c", TOML_TO_JSON, manifestPath], { encoding: "utf8", }); diff --git a/scripts/check-syntax.mjs b/scripts/check-syntax.mjs new file mode 100755 index 0000000..91c8160 --- /dev/null +++ b/scripts/check-syntax.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +let checked = 0; +for (const directory of ["src", "scripts", "hooks", "tests"]) { + for (const name of fs.readdirSync(path.join(root, directory)).sort()) { + const file = path.join(root, directory, name); + if (!fs.statSync(file).isFile()) continue; + const command = name.endsWith(".mjs") + ? [process.execPath, ["--check", file]] + : name.endsWith(".sh") ? ["bash", ["-n", file]] : null; + if (!command) continue; + const result = spawnSync(command[0], command[1], { stdio: "inherit" }); + if (result.error || result.status !== 0) { + console.error(`Syntax check failed: ${directory}/${name}${result.error ? `: ${result.error.message}` : ""}`); + process.exit(1); + } + checked += 1; + } +} +console.log(`Syntax valid: ${checked} JavaScript and shell files.`); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100755 index 0000000..1435d61 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { parseManifest } from "./check-manifest.mjs"; + +export function versionAtLeast(actual, minimum) { + const parse = (value) => + /^(?:v|herdr\s+)?(\d+)\.(\d+)(?:\.(\d+))?(?:\s.*)?$/.exec(value.trim()); + const have = parse(actual); + const need = parse(minimum); + if (!have || !need) return false; + for (let i = 1; i <= 3; i += 1) { + const difference = Number(have[i] ?? 0) - Number(need[i] ?? 0); + if (difference !== 0) return difference > 0; + } + return true; +} + +function main() { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + let failed = false; + const report = (ok, message) => { + console.log(`${ok ? "PASS" : "FAIL"} ${message}`); + if (!ok) failed = true; + }; + report( + versionAtLeast(process.version, pkg.engines.node.replace(/^>=/, "")), + `Node ${process.version}; requires ${pkg.engines.node}`, + ); + report( + ["darwin", "linux"].includes(process.platform), + `Platform ${process.platform}; supports macOS and Linux`, + ); + const lock = process.platform === "darwin" ? "/usr/bin/lockf" : "flock"; + const lockCheck = spawnSync("/bin/sh", [ + "-c", 'command -v "$1" >/dev/null', "doctor", lock, + ]); + report(!lockCheck.error && lockCheck.status === 0, `Lock utility ${lock}`); + try { + const manifest = parseManifest(path.join(root, "herdr-plugin.toml")); + report(true, "Python 3.11+ TOML parser available"); + const herdr = process.env.HERDR_BIN_PATH || "herdr"; + const result = spawnSync(herdr, ["--version"], { + encoding: "utf8", + timeout: 5000, + }); + const version = result.stdout?.trim() || "unavailable"; + report( + !result.error && result.status === 0 && versionAtLeast(version, manifest.min_herdr_version), + `${version}; requires Herdr >=${manifest.min_herdr_version} (HERDR_BIN_PATH or PATH)`, + ); + } catch (error) { + report(false, error.message); + } + console.log( + "Read-only checks only; run npm run validate for source checks and tests. No panes, hooks, or policy files changed.", + ); + process.exitCode = failed ? 1 : 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + main(); diff --git a/tests/doctor.test.mjs b/tests/doctor.test.mjs new file mode 100644 index 0000000..5fd86c8 --- /dev/null +++ b/tests/doctor.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { versionAtLeast } from "../scripts/doctor.mjs"; + +test("doctor compares stable Node and Herdr versions numerically", () => { + assert.equal(versionAtLeast("herdr 0.7.1", "0.7.5"), false); + assert.equal(versionAtLeast("herdr 0.7.5", "0.7.5"), true); + assert.equal(versionAtLeast("herdr 0.10.0", "0.7.5"), true); + assert.equal(versionAtLeast("v20.9.0", "20.10"), false); + assert.equal(versionAtLeast("v24.13.0", "20.10"), true); + assert.equal(versionAtLeast("v20.10.0", "20.10"), true); + assert.equal(versionAtLeast("0.7.5-rc.1", "0.7.5"), false); + assert.equal(versionAtLeast("unavailable", "0.7.5"), false); +});