diff --git a/.claude/settings.json b/.claude/settings.json index 573c48b5..959cd2c5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -240,18 +240,6 @@ ] } ], - "WorktreeCreate": [ - { - "hooks": [ - { - "type": "command", - "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node \"$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs\" --hook WorktreeCreate", - "timeout": 60, - "__failproofai_hook__": true - } - ] - } - ], "WorktreeRemove": [ { "hooks": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6c44ea..2ac1e427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,25 @@ - Lift the `brace-expansion` pin to 5.0.8 and the `postcss` pin to 8.5.23, clearing the two High-severity advisories that turned the Supply Chain gate red on every open PR for the third time this month. GHSA-mh99-v99m-4gvg (CVSS 7.5) is an unbounded-expansion DoS in `brace-expansion` `<= 5.0.7`, fixed in 5.0.8; GHSA-r28c-9q8g-f849 (CVSS 7.5) is a path traversal in `postcss`'s `sourceMappingURL` auto-loading that discloses arbitrary `.map` files, affecting `<= 8.5.17` and fixed in 8.5.18. Same shape as #587 and #591: both advisories published *after* `main`'s last green scan at 2026-07-24 15:12 UTC, so branches carrying no dependency change of their own — #594 is three docs pages — went red simultaneously. The remedy is narrower than either predecessor, because both packages were **already** pinned in `overrides` by those PRs and the vulnerable versions are the pins themselves; nothing resolves them but the pin, so the entire fix is to raise the two values. `brace-expansion` goes to 5.0.8, the only fixed 5.x, keeping the single-pin-across-two-majors arrangement #587 established (the `minimatch@3` that `eslint-plugin-import`, `-jsx-a11y` and `-react` pull still declares `^1.1.7`) — a patch bump inside v5, so it carries none of that decision's original risk, and lint still runs clean with the same five pre-existing warnings. `postcss` goes to 8.5.23 rather than the minimum fixed 8.5.18 because the old 8.5.14 pin was *below* what its own dependents ask for — `@tailwindcss/postcss` declares `^8.5.16` and `vite` `^8.5.15` — so the bump also stops the override silently forcing a version outside both ranges; it pulls `nanoid` 3.3.12 → 3.3.16 with it. Verified beyond a green lockfile, since `postcss` is a build-time dependency: `bun run build` completes (Next.js + Tailwind + `dist/index.js`) and all 2357 unit tests pass. Confirmed with CI's own scanner image (`ghcr.io/google/osv-scanner-action:v2.3.8`) against the updated lockfile: `No issues found`, exit 0, with `osv-scanner.toml` keeping its zero ignored vulnerabilities. (#596) ### Features +- Correct three wrong claims in the Hermes capability table, verified against upstream `hermes-agent` @ `5771a6e`. `agent/shell_hooks.py:567-621` (`_parse_response`) is **event-gated** — it returns a verdict for `pre_tool_call` and `pre_verify` only and falls through to `return None` for everything else. So: **`subagent_stop` was documented "✅ block" and is not a gate** — the parser never returns a verdict for it and the call site discards the return anyway (`tools/delegate_tool.py:2677`), meaning any customer policy denying on SubagentStop had **zero** enforcement for as long as that row stood; **"Hermes has no turn-end `Stop` event" was false** — `pre_verify` is exactly that, and it accepts our Claude Stop shape verbatim, though we deliberately do not install it, so the 5 `require-*-before-stop` builtins stay inapplicable there by choice rather than by platform limit (the conditions it would carry are recorded in `types.ts` so the decision can be revisited without re-deriving them); and **"no additional-context channel" was false** — `pre_llm_call` consumes `{"context": str}` via the parser's fallthrough, which we also do not install, so `instruct()` still degrades to allow plus a stderr note. The machine-readable version of this table now lives in `src/hooks/enforcement-capability.ts` with a test behind it; the prose table is the artifact that drifted. (#622) +- Tell users when a policy ran but the CLI cannot enforce on that event. A deny already explains itself in the activity log; an observation-only event explained nothing — the row looked identical whether a policy fired and was ignored, or no policy covered the event at all. That ambiguity is what made a customer's Hermes guardrails look broken when they were running fine. Two pieces were missing. Activity rows now carry `matchedPolicies`, every policy that MATCHED and ran, because `policyName` names only the policy that *decided* and is null on a plain allow — so there was no name to put in a message. And `src/hooks/enforcement-capability.ts` records, per CLI per canonical event, whether a deny is read at a call site that changes control flow, established by tracing all 12 CLIs against upstream source, vendor contracts or recorded live probes (41 `block` cells, 64 `observe`). The expanded log row now says which policy ran, that the CLI discards verdicts for that event, and where to move the check. **An absent entry means unverified and renders nothing** — a hedge in the UI is still a claim, and unverified claims are precisely what this replaces: the same capability table lived in CLAUDE.md prose, where it drifted into documenting Hermes `subagent_stop` as a working gate while upstream discarded its return, so customer policies on that event enforced nothing for months. Tests pin the shape, the canonical event names, and that lookup never guesses a default. (#622) +- Render the user-scoped gateway CLIs as a collapsible folder tree in the projects panel, and group OpenClaw by agent as well as channel. Hermes and OpenClaw have no working directory to group by — that is why they were flattened into one row per channel — so what they get instead is the hierarchy they actually have: profile (or agent) as a folder, its channels beneath it, sessions inside those. Every cwd-based CLI keeps rendering as a flat row, because path-segment nesting would change how people navigate their own repos for a problem they don't have. The tree is a pure presentation transform over the segmented `path` the producers already emit (`lib/project-tree.ts`, unit-tested with no fixtures), so project *names* are untouched and every existing `/project/` link, bookmark and stored audit result still resolves. Pagination moves to top-level rows, and the tree spans every filtered project rather than a page slice: a profile's channels are separate projects interleaved by recency, so slicing first would split one profile across two pages — rendering its root twice, each copy rolling up only the sessions that landed on that page, with the true total shown nowhere. The summary still counts projects ("Showing 1-27 of 27 projects"), derived from what the displayed roots actually cover, and for anyone with no gateway CLIs every root is exactly one project so the ranges are unchanged. Folder rows roll up session count and newest activity from everything beneath them, so an idle profile is obvious without expanding; the count comes only from the gateway producers, where the grouping loop already visits every session, and stays `undefined` elsewhere rather than rendering a `0` that would read as "empty". Rows default to EXPANDED and persist what you collapse — pagination already bounds the size, so defaulting to collapsed would have hidden everything behind a click to solve a problem that can't occur — and an active search force-expands, since a match hidden inside a collapsed folder is worse than no match at all. OpenClaw's grouping changes with it: it now emits `openclaw--` instead of `openclaw-`, which fixes two different agents that both talk on Telegram silently collapsing into one row with one mixed session list, and makes the dashboard agree with the audit adapter — the two previously sliced identical data on different axes (adapter by agent, panel by channel). Legacy `openclaw-` links keep resolving, to every agent on that channel, which is exactly what they used to mean. Agent ids and profile names may both contain `-`, so names are parsed by longest-matching against the agents/profiles actually on disk rather than splitting the slug. (#622) +- Group Hermes sessions by profile as well as channel, so the projects panel reads `hermes/work/slack` rather than collapsing every profile's Slack traffic into one `hermes/slack` card. A Hermes profile is not a setting but a whole separate home directory — its own `config.yaml`, `.env`, `SOUL.md` and `state.db` — so it is the outer scope and `source` (slack/telegram/cli/cron) splits the channels inside it; the audit adapter groups the same way (`hermes:work:telegram`), and a session's synthesized `cwd` now matches its `projectName` instead of disagreeing with it. Three details make this cheaper than it looks. `decodeFolderName()` already renders `-` as `/`, so a project named `hermes-work-slack` displays as `hermes/work/slack` with no UI change at all. The name is parsed back by matching against the profiles actually on disk, longest first, rather than splitting on `-` — a profile named `my-bot` would otherwise round-trip into a profile `my` in channel `bot-cron` — and an unrecognised prefix falls back to the old `hermes-` form, so links made before profile support still resolve. And `hermes://` is deliberately left alone: rather than encoding the profile into the identifier, session lookup searches each profile's DB and returns the first hit, which keeps transcript paths already recorded in audit history valid and leaves the download route, its id validator and `resolve-transcript-path.ts` untouched. Session ids are `YYYYMMDD_HHMMSS_`, so a cross-profile collision is not a practical concern, and profile order is deterministic. Because `src/audit/cache.ts` keys on `(transcriptPath, mtimeMs, sizeBytes)` and not on `projectName`, nothing re-scans — only the grouping label changes. (#622) - Probe each agent CLI's public pre-release build alongside the one users get, so the integration suite reports not only "is enforcement broken?" but "is it about to break, and how long do we have?". The workflow becomes a matrix over `CANARY_CHANNEL`: the `stable` leg is unchanged (all 12 CLIs, a `FAIL` still fails the job) and a new `beta` leg installs each vendor's pre-release ref. The motivating measurement: codex shipped the payload change that broke the 2026-07-22 run in `0.145.0-alpha.4` on 2026-07-11 but did not release it until 2026-07-21 — **10.7 days** of warning, established by installing each alpha and capturing its request body against a mock endpoint. Six CLIs publish something usable: codex `alpha`, copilot `prerelease`, openclaw `beta`, cursor `?channel=lab` (server-validated; `beta`/`canary` are rejected with HTTP 400), goose `CANARY=true` (a *rolling tag* marked `prerelease: false`, so channel detection scanning for `prerelease: true` misses it), and claude — which runs **backwards**: nothing ships ahead of `latest`, so the stable leg now pins `bash -s stable` (~13 days behind, and what conservative users actually run) and `latest` becomes the early-warning ref. That pin also stops a same-day Anthropic release, which lands ~daily, from red-lighting a PR that touched nothing. The other six are deliberately skipped rather than re-installed at stable and reported as coverage: factory, antigravity and pi publish no pre-release at all (verified — antigravity has 19 releases with zero prereleases and its updater serves the identical stable manifest for every `?channel=` value); opencode's `beta`/`dev` tags are ~31 branch snapshots a day rather than release candidates; devin's unpromoted builds exist but cannot be listed, only guessed at; and hermes's installer git-clones `main`, which is also what every hermes user gets, so there is nothing ahead of us to probe. Coverage is stated in the report itself (`watching 6/12`) because a pre-release ref does not preview every release — codex only alphas minor bumps, so all six recent `0.144.x` patches shipped blind, and the copilot 1.0.70 silent-allow had just 0.6 days of lead. (#591) - Escalate incoming breakage on a cross-leg comparison rather than a beta failure. A CLI red on both legs is already broken and belongs to the stable leg's alarm; the early warning is `stable green + beta not-green`, held for two consecutive runs so an alpha that gets reverted before release doesn't burn attention. This distinction is load-bearing: a vendor payload change stops the model *before* it calls a tool, so it surfaces as `INCONCLUSIVE`/`ERROR` and never as `FAIL` — a FAIL-only rule would have watched the codex regression sail past for ten days in silence. The beta leg is advisory throughout: `run.sh` exits 0 on it regardless, each leg gets its own Docker volume (a pre-release install overwrites the stable binary in a shared `$HOME`) and its own Actions cache key (the legs run concurrently, and a beta result must never overwrite the stable leg's gating record). `__tests__/integration-suite/channel-refs.test.ts` asserts that `run.sh`'s beta CLI list and `install-clis.sh`'s beta refs agree, since a silent drift there would install stable binaries and report them as pre-release coverage — a false all-clear, the worst failure available to an early-warning system. (#591) ### Fixes +- Close two regressions this branch introduced, both found by running the case rather than reading it. **A policy file reachable by two discovery routes ran twice per event.** The previous fix deduplicated the project and user convention directories against each other, but not against an explicit `customPoliciesPath` — and pointing that at a file inside `.failproofai/policies/` whose name also matches the convention is an ordinary setup, exactly what `failproofai policies -i -c .failproofai/policies/my-policies.mjs` produces. `loadAllCustomHooks` then imported it once as the explicit path and again as a convention file; since `customPolicies.add` is an unconditional push, every hook in it registered twice and fired twice per event, doubling side effects, cost and any counter the policy keeps. Reproduced against the published `dist/cli.mjs`: the module body evaluated twice and the policy function ran twice, with the activity row listing `custom/` and `.failproofai-project/` side by side. Node-only, and therefore invisible from both directions — Bun ignores the `?v=` cache-buster so the duplicate import is a silent no-op in a `bin/failproofai.mjs` run, while Node honours it and re-executes. All three routes now dedupe through one set of resolved paths. **A hand-edited `settings.json` could abort the whole install.** The new pruning loop walks every key in `settings.hooks`, including keys failproofai has never written — a newer Claude event, another tool's entry, a typo — so their values are unvalidated input, and a non-array one threw (`{} is not iterable`, or `matchers.filter is not a function`). The throw escapes `installHooks` *after* the selected policies are recorded as enabled, so the user is told they are covered while settings.json received no hook at all: silent non-enforcement, the failure this pruning exists to remove. Verified end to end — `Unexpected error: {} is not iterable`, 0 hooks written, against 28 on the same file with the key well-formed. Non-array values are now skipped, and a matcher group is dropped only when *we* emptied it, so foreign and malformed entries are written back exactly as found instead of being quietly deleted. (#622) +- Address the CodeRabbit review on #622. The substantive one: convention discovery loaded the user directory even when it resolved to the same path as the project directory — which is what happens whenever the project root IS the home directory, the normal setup for a gateway. Every file was then loaded twice and `customPolicies.add` (an unconditional push) registered each hook twice, so every policy fired twice per event; a counting policy would double-count and trip its ceiling at half the real number. That was masked by the runtime rather than prevented, in a way that hid it from both sides: **Bun caches dynamic imports by resolved path and ignores the `?v=` cache-buster**, so the second import was a no-op in the shipped binary, while **Node honours the query** and would have double-registered — and the test suite runs under Node, where nothing exercised the overlapping-directory case. Paths are now deduplicated explicitly, with a regression test that fails without it, and the cache-buster carries a note that nothing may depend on a repeat load re-executing. Also: one unreadable policy file no longer blanks the entire Configure Policies tab (`readFile` still throws on EACCES or a raced delete even though existence is checked, and both callers swallow the rejection, so the tab hung on "Loading…" — discovery is now isolated per directory and per file, listing a file it cannot read rather than failing the payload); the convention tests stub `FAILPROOFAI_LAUNCH_CWD` so an ambient value cannot bypass the cwd spy; and absolute developer home paths are out of shipped source, since the version alone identifies the artifact. (#622) +- Fix three defects an adversarial review of this branch confirmed, two of them regressions introduced by the branch itself. **Pi's tool gate was turned OFF.** The commit that corrected five inert deny shapes put Pi's two returns in each other's handlers: both build the same `hook_event_name: "PreToolUse"` payload (`user_bash` maps there too), so a single search-and-replace matched the wrong one. `tool_call` — every tool the model calls — returned `{result:BashResult}`, which `ToolCallEventResult` has no field for, so `agent-loop`'s `beforeResult?.block` was undefined and the tool ran; `user_bash` kept the `{block:true}` that `UserBashEventResult` has no field for. Strictly worse than the bug being fixed, and the capability matrix asserted both were fine, so no UI caveat covered the hole. Returns are now in their correct handlers, pinned by a test that reads each handler's source and fails if the shapes swap again. **`claude --worktree` was still broken in this repo**: `WorktreeCreate` was dropped from what we install but never removed from the committed `.claude/settings.json`, and the dogfood tripwire asserted a bare event *count*, which a swap leaves unchanged — it now also asserts no registered event lies outside `CLAUDE_INSTALL_EVENT_TYPES`, an invariant a count cannot express. **Project-scope policies were invisible in the shipped dashboard**: the server action resolved from `process.cwd()`, but `.next/standalone/server.js` calls `process.chdir(__dirname)` on its first line, so at request time cwd is the installed package; and unlike enforcement it did not walk up to the nearest `.failproofai` marker, so it disagreed from any subdirectory. The launcher now forwards the pre-chdir cwd as `FAILPROOFAI_LAUNCH_CWD` and both the action and `failproofai policies` resolve through `findProjectConfigDir`, so listing and enforcement agree. (#622) +- Never overwrite a `policies-config.json` that does not parse. `readScopedHooksConfig` fails soft, returning `{enabledPolicies: []}` on a syntax error — correct for the hook path, which must not die because a config is malformed, but fatal for a writer: the new convention-policy record would write that empty default straight back, **destroying every enabled policy the user had**. One stray comma in a hand-edited config plus a `failproofai policies` run — a read-only command — was enough to wipe the file, silently. `syncConventionPolicies` now re-parses the file itself before writing and bails with a warning naming the path, leaving it byte-for-byte untouched. Caught by running the case rather than reasoning about it, and pinned by two tests that fail without the guard. (#622) +- Send deny verdicts in the shape five CLI events actually parse, turning silent non-enforcement into real blocks. Each of these ran the policy, produced a verdict, recorded `decision: "deny"` in the activity store and counted it in telemetry — while the CLI submitted the prompt or ran the command anyway, because the shape we emitted matched nothing it reads. Every one of them *can* block; the gap was ours, not the vendor's, which makes them worse than a missing capability: the dashboard reported protection that did not exist. **Copilot `UserPromptSubmit`** now emits `{decision:"block",reason}` at exit 0 — we were sending exit 2 + stderr, which Copilot logs as `Hook command exited with code 2 (warning)` for every event and never treats as a deny. **Copilot `PermissionRequest`** now emits the flat `{behavior,message}` its normalizer consumes; the Codex-shaped nested `hookSpecificOutput.decision` normalized to `{}`, so the permission prompt proceeded as if no policy existed. **Cursor `UserPromptSubmit`** now emits `{continue:false,user_message}`, the only block key `beforeSubmitPrompt` reads — an object carrying `permission:"deny"` validates as unknown-key and is dropped (the tool events, which genuinely do read `permission`, are unchanged and covered by a regression test). **Pi `input`** now returns `{action:"handled"}`: `InputEventResult` is a union of continue/transform/handled with no `block` field at all, verified against the installed package's own `.d.ts`, so `{block:true}` matched neither branch — with the caveat that `handled` drops the prompt silently, so the shim logs the reason to stderr. **Pi `user_bash`** now returns a full-replacement `{result:BashResult}`, the documented way for an extension to declare it handled execution; `UserBashEventResult` has no `block` field either, so the command was running. The capability matrix moves those cells from `observe` to `block` with the fix recorded inline. (#622) +- Stop registering a `WorktreeCreate` hook on Claude Code, which broke `claude --worktree` and `/worktree` for every user who installed failproofai. That event is not a permission gate: Claude uses it as a worktree-PATH PROVIDER, taking the stdout of the first hook that succeeds as the directory to create and failing with `WorktreeCreate hook failed` when none supplies one. Our allow path writes nothing to stdout — correctly, by the contract every other event uses — so merely being registered there broke the feature whatever any policy decided, and no policy was even involved. Found by auditing all 12 CLIs' hook contracts against upstream source; confirmed locally (the hook was present in `~/.claude/settings.json` and the binary returned empty stdout for the event). Claude now installs from `CLAUDE_INSTALL_EVENT_TYPES` — the canonical set minus that one event — while `HOOK_EVENT_TYPES` keeps its full 29 so a policy may still subscribe. Nothing is lost: all 39 builtins match only PreToolUse, PostToolUse, PermissionRequest and Stop. `writeHookEntries` also now prunes our marked entry from any event it no longer installs, so reinstalling repairs a machine that already has the broken entry rather than leaving it in place forever; a third party's own hooks on the same event are untouched. (#622) +- Decide the integration suite's PASS/FAIL on what the CLI did, not on what we logged. Each probe gathers two pieces of evidence — our `hooks.log` (did failproofai emit a deny?) and a side effect on disk (did the CLI run the command regardless?) — and the verdict read our own log first, so a CLI that logged our deny and then ran the command anyway matched the leading branch and scored **PASS** while the marker file proving it ran sat unread. That silent allow is the exact failure the suite exists to catch: copilot 1.0.70 shipped it. Both probes now check ground truth first — the marker file for the bash probe, the leaked sentinel for the read probe — and fall through to the hook log only once the action is known not to have happened. Until this landed, no result from the suite was evidence that any CLI honours a deny; every green only proved we emitted one. A new tripwire asserts the ordering, including a general guard against any verdict block that opens by scoring PASS from the hook log alone. (#622) +- Indent the folder icon with its label in the projects tree, so a nested gateway folder starts to the right of its parent instead of level with it. The icon renders in its own `` (the tree is one table, so the Path and Last Modified columns stay aligned down the page) and only the name cell carried the depth padding — every child's icon therefore sat at exactly the same x as its parent's, and a Hermes profile's channels read as a flat list with ragged text rather than a hierarchy. Both cells now take the same offset, so icon and label move together one step per level, and a test pins that each depth starts further right than the one above it. (#622) +- Record convention policies in `policies-config.json`, so the config shows what is installed and not only what was explicitly enabled. Dropping a file into `.failproofai/policies/` wrote nothing anywhere — enabling a builtin appended to `enabledPolicies` and `--install -c` wrote `customPoliciesPath`, but the convention path left the file untouched, so an operator with four working policies saw `{"enabledPolicies": []}` and concluded discovery was broken. `failproofai policies` now mirrors what it lists into that scope's config under a new `conventionPolicies` key, recording each file and the hook names it actually registered (taken from the load the listing already performs, so they are real names rather than a regex guess). The record is **descriptive, never authoritative**: enforcement still discovers from the filesystem and never reads the key, which is pinned by a test asserting a policy fires while the config lists a different, deleted file — an opt-in registry would mean a freshly-copied policy silently doing nothing until some command refreshed it, the exact silent-non-enforcement this project exists to remove. It is written wholesale rather than merged, so a deleted file disappears on the next run; only when the value changes, so repeated runs do not churn the file; never when the list is empty and no config exists, so the CLI does not litter config files in every directory it runs from; and never from the hook path, where a read-modify-write from concurrent short-lived processes with no locking would corrupt the file that governs enforcement. Loading the same file twice in one process also stopped returning zero hooks: the temp-file URL was deterministic, so the second dynamic import hit the ESM module cache, the module body never re-ran, and no `customPolicies.add` fired — which is what rendered every file as `failed to load` when the listing walked a shared project/user directory twice. The specifier now carries a monotonic cache-busting query. (#622) +- Show convention-discovered policies in the dashboard, and stop the CLI's own listing running the filename into its hook count. Both halves are the same root confusion: `.failproofai/policies/*policies.mjs` files are registered by the **filesystem**, never by `policies-config.json`, so a dashboard that reads only config renders nothing and a working policy looks uninstalled. Reported from a live gateway where `failproofai policies` listed four `enforce-*` convention files with green ticks while the configure view showed none, and the operator reasonably concluded discovery was broken — it was not; `enabledPolicies` is the builtin allowlist and never held custom names. `get-hooks-config.ts` now calls the same `discoverPolicyFiles()` the hook path and `manager.ts` already use, for project and user scope, and returns them grouped by declaring file so the UI can show which file to edit. It **parses** rather than imports them: `manager.ts` executes each file to list it, which is fine for a one-shot CLI but would run arbitrary user code inside the long-lived dashboard server on every page load, so the existing regex reader is reused and a malformed file degrades to "no policies listed" instead of taking the server down. Running the dashboard from `$HOME` makes the project and user directories identical, which would list every file twice as if it were two installs, so that case collapses to one. The CLI half is narrower: `nameColWidth` is sized to the longest *builtin* name (32 chars) and `padEnd` to a width below the string length is a no-op, so a 42-character filename printed as `enforce-bengaluru-event-links-policies.mjs1 hook(s)` with no separator at all; the column now widens to fit the files actually being printed. Both are covered by tests that fail without the fix, including one asserting the exact reported collision. Running the CLI from `$HOME` also made the project and user convention directories the same path, so the listing walked it twice — and the second pass rendered every file as `failed to load`, because the first had already imported the module and the ESM cache short-circuits `customPolicies.add`, leaving `loadCustomHooks` to return 0 hooks truthfully. Four working policies all showed a red ✗ on a live install. The shared directory is now listed once, labelled `Project + User` so the collapse is visible rather than silent. (#622) +- Stop hardcoding `~/.hermes`, which left every non-default Hermes profile both unaudited and unenforced. Hermes profiles are separate home directories (`~/.hermes/profiles//`), each with its own `state.db` and its own `config.yaml`, and both pillars assumed there was exactly one of each — upstream's own contributor guide warns that hardcoding the path breaks profiles, and it broke both halves in different ways. On the audit side those sessions were not mis-grouped but **absent**: the dashboard and `failproofai audit` read one `state.db`, so a gateway serving several profiles showed only the default one's traffic with nothing indicating the rest existed. On the enforcement side `policies --install --cli hermes` wrote one `config.yaml`, so every other profile ran with no hooks at all — silently, since Hermes reports nothing about hooks it was never told to run, which is the same silent-unenforcement failure class as the Copilot config drift. Discovery now lives in one dependency-light module both pillars share (`lib/hermes-profiles.ts`, fs/path only so the hook path pays nothing for it), and it honours `HERMES_HOME` including the case that matters most: the per-profile alias wrapper exports `HERMES_HOME=/profiles/`, so discovery climbs back to the root and still sees every sibling, mirroring what upstream does for `hermes profile list`. `HERMES_DB_PATH` keeps its existing meaning as a single-DB override. Enforcement grew the smallest interface change that covers it — an optional `getSettingsPaths()` defaulting to `[getSettingsPath()]`, so the four call sites loop and the other eleven integrations are untouched — and Hermes now reports itself installed only when *every* profile is hooked, since a profile added after install would otherwise read as green while running unenforced; the wizard names the unhooked ones rather than flatly saying "not configured". A profile directory with no `state.db` is skipped rather than failing the batch. (#622) - Stop the workflow handing the stable leg a peer-state path pointing at its own state file, and stop the beta report overstating coverage on a targeted run (both caught in review by CodeRabbit). The first is the GitHub Actions ternary trap the workflow already documents thirty lines above for `CANARY_VERSION_GATED`: Actions has no ternary, `cond && a || b` is the idiom, and an empty `a` is *falsy* — so `matrix.channel == 'stable' && ''` short-circuits to the fallback and both legs received the same path. Latent rather than active (`report.js` only reads the peer state on the beta leg) but a landmine, and fixed by flipping the operands so the true branch is the non-empty one. The second: `run.sh` accepts a CLI subset, so `run.sh cursor` on the beta leg reported "the rest publish no pre-release ref" about CLIs that were merely not requested — precisely the overstated-coverage failure the line exists to prevent. The denominator is now the eligible count passed in from `run.sh`, rather than a hardcoded 12 or a third copy of the CLI list to drift against, so a targeted run reads `watching 2/6 CLIs that publish a pre-release ref (of 12 total)`. Both are covered by new assertions in `__tests__/integration-suite/channel-refs.test.ts`. (#591) - Repoint the integration suite's codex probe at `gpt-5.1-codex-mini`, restoring the daily enforcement signal for that CLI. The 2026-07-22 run reported `codex bash=INCONCLUSIVE read=INCONCLUSIVE` while the other eleven CLIs stayed green, and the cause was entirely vendor-side: codex-cli shipped 0.145.0 overnight (0.144.6 was green the day before), and for a model it has no metadata for — deepseek logs `Model metadata not found. Defaulting to fallback metadata` — it now sends `reasoning:{summary:"auto"}` and `include:["reasoning.encrypted_content"]` where 0.144.6 sent `reasoning:null` and `include:[]`. The gateway answers `400 "Encrypted content is not supported with this model"` (`param: include`), codex exits before its first tool call, and with no tool call there is no deny to observe, so both probes report INCONCLUSIVE. Enforcement was never broken: the hook log shows `SessionStart` and `UserPromptSubmit` firing in every failed run, and a silent-allow would have surfaced as FAIL, not INCONCLUSIVE. Confirmed by reproducing the CI verdict locally against the real gateway and by replaying the captured request body with and without those two fields — the same body minus `include`/`reasoning` returns 200 on deepseek. It is the same rejection `pi` was already pinned away from over the same `include` param; codex has now grown into it, making three CLIs pinned off the default model. No config override avoids it — `model_reasoning_summary="none"`, `model_supports_reasoning_summaries=false` and `model_reasoning_effort="none"` all still emit `include` — and the escape hatch is gone, since `wire_api = "chat"` is rejected outright by 0.145.0 (openai/codex#7782). `gpt-5.1-codex-mini` is the cheapest gateway model that accepts encrypted reasoning content *and* supports codex's full toolset; `gpt-5.4-nano` accepts the reasoning params but 400s on `tool_search`, which only an end-to-end run reveals. Verified end to end: `bash=PASS read=PASS` with `result=deny policy=custom/canary-bash` and `custom/canary-read` in the oracle. (#591) - Make `CANARY_CODEX_MODEL` actually reach the probe. `probe-cli.sh` has read the override since the harness was written, but nothing ever set it: only `CANARY_LLM_MODEL`, `CANARY_CLAUDE_MODEL` and `CANARY_PI_MODEL` were written into the container env-file, and the probe runs inside the container, so setting the variable in repo settings would have had no effect at all. `ci-entrypoint.sh` now forwards it and the workflow maps an optional secret, so the next model that starts refusing codex's payload can be swapped from repo settings without a code change. (#591) diff --git a/CLAUDE.md b/CLAUDE.md index 2e84b5f1..c7140786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -367,17 +367,48 @@ internal tool calls don't fire Hermes hooks — gate the *spawn* at `pre_tool_ca | `post_tool_call` | `PostToolUse` | observation | Observe / sanitize. | | `on_session_start` | `SessionStart` | observation | — | | `on_session_end` | `SessionEnd` | observation | — | -| `subagent_stop` | `SubagentStop` | ✅ block | Subagent-return gate. | - -**Limitations vs. Claude semantics.** Hermes has **no turn-end `Stop` event** — its -lifecycle is session-oriented and it can't force another turn into a session that's -ending — so `HERMES_EVENT_MAP` never emits `Stop` and the 5 `require-*-before-stop` -builtins never fire for it (inapplicable, not broken). It also lacks `UserPromptSubmit` -(only per-LLM-call `pre_llm_call`), `PreCompact`/`Notification`, etc. `instruct()` -degrades to **allow + logged note** — Hermes has no additional-context channel, so the -evaluator emits a non-blocking `{"decision":"allow", reason}` and surfaces the note on -stderr. In exchange Hermes has capabilities others lack (`transform_tool_result`, -`pre_gateway_dispatch`, `pre_llm_call`) — out of scope for now. +| `subagent_stop` | `SubagentStop` | observation | **NOT a gate** — see the correction below. | +| `pre_verify` | *(not installed)* | ✅ block | Real turn-end gate upstream — **we deliberately do not install it**; see below. | + +**Corrections (2026-07-29).** Three claims that stood here were wrong, each verified +against upstream `hermes-agent` @ `5771a6e`. `agent/shell_hooks.py:567-621` +(`_parse_response`) is **event-gated**: it returns a verdict for `pre_tool_call` and +`pre_verify` only, and falls through to `return None` for everything else. So: + +1. **`subagent_stop` was documented "✅ block" and is not one.** The parser never + returns a verdict for it, and the call site discards the return anyway + (`tools/delegate_tool.py:2677`, a bare `invoke_hook(...)`). Any customer policy + denying on SubagentStop had **zero** enforcement for as long as that row stood. +2. **"Hermes has no turn-end `Stop` event" was false.** `pre_verify` is exactly that. + Upstream fires it once per turn when the agent has edited code and is about to + finish (`agent/conversation_loop.py:6754`); its parser accepts our Claude Stop + shape verbatim — `{decision:"block",reason}` reads as "block the stop", i.e. keep + going — and the reason is injected as a synthetic user message before the loop + re-enters (`conversation_loop.py:6774-6800`). Installing it is one entry in + `config.yaml`; it is a shell hook like the other five, no Python involved. + + **We deliberately do not install it** (product decision, 2026-07-29). So + `HERMES_EVENT_MAP` emits no `Stop` and the 5 `require-*-before-stop` builtins + remain **inapplicable** on Hermes — but that is now a choice, not a platform + limit, and this is what it would buy if revisited. Three upstream conditions + would apply: it fires only on turns that landed `write_file`/`patch` + (`agent/tool_result_classification.py:9` — a `terminal`-only turn does not qualify, + so a chat-only gateway may never see it), it is capped at 3 nudges per turn + (`agent/verify_hooks.py:21`, operator-overridable), and on Hermes older than + ~2026-06-30 the config key fails `VALID_HOOKS` and is **warn-and-skipped silently** + (`agent/shell_hooks.py:325`). +3. **"No additional-context channel" was false.** `pre_llm_call` consumes + `{"context": str}` via the parser's fallthrough (`shell_hooks.py:617-621`). We do + not install it, so `instruct()` still degrades to allow + a stderr note — but that + is now a gap we chose, not a limit of the platform. + +Hermes still lacks `UserPromptSubmit` (only per-LLM-call `pre_llm_call`), +`PreCompact`/`Notification`, etc. In exchange it has capabilities others lack +(`transform_tool_result`, `pre_gateway_dispatch`, `pre_llm_call`) — out of scope for now. + +The authoritative, machine-readable version of this table is now +`src/hooks/enforcement-capability.ts`, which a test asserts against. Prefer it over this +prose: this table is exactly the artifact that drifted. **Tool-input canonicalization.** `HERMES_TOOL_MAP` canonicalizes tool *names* and `HERMES_TOOL_INPUT_MAP` the *argument keys*. Verified against a live `~/.hermes/state.db`: diff --git a/__tests__/actions/get-hooks-config.test.ts b/__tests__/actions/get-hooks-config.test.ts index b88cce8a..d1357cad 100644 --- a/__tests__/actions/get-hooks-config.test.ts +++ b/__tests__/actions/get-hooks-config.test.ts @@ -1,8 +1,14 @@ // @vitest-environment node -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("@/src/hooks/hooks-config", () => ({ readHooksConfig: () => ({ enabledPolicies: [] }), + // The action walks up to the project root like enforcement does; these tests + // point cwd straight at the project root, so identity is the right stub. + findProjectConfigDir: (start: string) => start, })); vi.mock("@/src/hooks/manager", () => ({ @@ -91,3 +97,114 @@ describe("getHooksConfigAction — clis payload", () => { expect(config.clis.find((c) => c.id === "codex")!.label).toBe("OpenAI Codex"); }); }); + +// ── Convention policies ────────────────────────────────────────────────────── +// +// These are registered by the filesystem, never by policies-config.json, so a +// dashboard that only reads config renders nothing and a working policy looks +// absent. That was the reported bug: `failproofai policies` listed four +// convention files while the dashboard's configure view showed none. +describe("getHooksConfigAction — convention policies", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fp-convention-")); + // The action prefers FAILPROOFAI_LAUNCH_CWD over process.cwd(); if it is + // set in the shell or the CI image, the cwd spy below is bypassed and every + // test here silently resolves against the wrong root. + vi.stubEnv("FAILPROOFAI_LAUNCH_CWD", ""); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + function writePolicyDir(root: string, files: Record) { + const dir = join(root, ".failproofai", "policies"); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body, "utf8"); + } + return dir; + } + + const POLICY_SRC = ` + import { customPolicies, allow } from "failproofai"; + customPolicies.add({ + name: "enforce-formal-review", + description: "Require a formal review", + match: { events: ["PreToolUse", "Stop"] }, + fn: async () => allow(), + }); + `; + + it("discovers project-scope policy files and parses their hooks", async () => { + writePolicyDir(tmp, { "enforce-formal-review-policies.mjs": POLICY_SRC }); + const spy = vi.spyOn(process, "cwd").mockReturnValue(tmp); + try { + const config = await getHooksConfigAction(); + const entry = config.conventionPolicies.find( + (e) => e.file === "enforce-formal-review-policies.mjs", + ); + expect(entry).toBeDefined(); + expect(entry!.scope).toBe("project"); + expect(entry!.policies).toEqual([ + { + name: "enforce-formal-review", + description: "Require a formal review", + eventScope: "PreToolUse, Stop", + }, + ]); + } finally { + spy.mockRestore(); + } + }); + + it("skips files that do not match the *policies.{js,mjs,ts} convention", async () => { + writePolicyDir(tmp, { + "enforce-formal-review-policies.mjs": POLICY_SRC, + // Silently skipped by the loader too — listing it would tell the user a + // file is active when it enforces nothing. + "block-foo.mjs": POLICY_SRC, + }); + const spy = vi.spyOn(process, "cwd").mockReturnValue(tmp); + try { + const config = await getHooksConfigAction(); + const files = config.conventionPolicies.map((e) => e.file); + expect(files).toContain("enforce-formal-review-policies.mjs"); + expect(files).not.toContain("block-foo.mjs"); + } finally { + spy.mockRestore(); + } + }); + + it("returns an empty list when no policy directory exists", async () => { + // HOME must be isolated too, or the developer's own user-scope policies + // leak into the assertion. + const emptyHome = mkdtempSync(join(tmpdir(), "fp-home-")); + const spy = vi.spyOn(process, "cwd").mockReturnValue(tmp); + vi.stubEnv("HOME", emptyHome); + vi.stubEnv("USERPROFILE", emptyHome); + try { + const config = await getHooksConfigAction(); + expect(config.conventionPolicies).toEqual([]); + } finally { + spy.mockRestore(); + rmSync(emptyHome, { recursive: true, force: true }); + } + }); + + it("does not list the same file twice when cwd is the home directory", async () => { + writePolicyDir(tmp, { "team-policies.mjs": POLICY_SRC }); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmp); + vi.stubEnv("HOME", tmp); + try { + const config = await getHooksConfigAction(); + const matching = config.conventionPolicies.filter((e) => e.file === "team-policies.mjs"); + expect(matching).toHaveLength(1); + } finally { + cwdSpy.mockRestore(); + } + }); +}); diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 6d5440af..3d662c3e 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ProjectList from "@/app/components/project-list"; @@ -20,7 +20,11 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/", })); -// Mock lucide-react icons to simple spans +// Mock lucide-react icons to simple spans. +// NOTE: this is a closed whitelist — an icon imported by the component but +// missing here resolves to `undefined` and React throws "Element type is +// invalid", failing EVERY test in this file with an error that points at the +// component rather than at this mock. Add new icons here. vi.mock("lucide-react", () => ({ Folder: ({ className }: any) => , Search: ({ className }: any) => , @@ -28,6 +32,7 @@ vi.mock("lucide-react", () => ({ Calendar: ({ className }: any) => , ChevronLeft: ({ className }: any) => , ChevronRight: ({ className }: any) => , + ChevronDown: ({ className }: any) => , RefreshCw: ({ className }: any) => , })); @@ -350,3 +355,288 @@ describe("ProjectList", () => { expect(screen.queryByText("/home/u/codex-only")).not.toBeInTheDocument(); }); }); + +// Gateway CLIs (Hermes, OpenClaw) are user-scoped and have no working directory +// to group by, so they render as a folder tree over their synthetic path. +// Filesystem-backed projects must keep rendering as flat rows. +describe("ProjectList — gateway folder tree", () => { + // Collapsed folders persist to localStorage by design, and jsdom shares one + // storage across every test in this file — so without this, a test that + // collapses a node leaves the next one starting collapsed. + beforeEach(() => { + window.localStorage.clear(); + }); + + function gatewayFolders(): ProjectFolder[] { + return [ + { + name: "hermes-default-slack", + path: "hermes:default:slack", + isDirectory: true, + lastModified: new Date("2026-07-20T00:00:00Z"), + lastModifiedFormatted: "Jul 20, 2026", + cli: ["hermes"], + sessionCount: 3, + }, + { + name: "hermes-bangalore-weather-telegram", + path: "hermes:bangalore-weather:telegram", + isDirectory: true, + lastModified: new Date("2026-07-21T00:00:00Z"), + lastModifiedFormatted: "Jul 21, 2026", + cli: ["hermes"], + sessionCount: 8, + }, + { + name: "-home-u-app", + path: "/home/u/app", + isDirectory: true, + lastModified: new Date("2026-07-19T00:00:00Z"), + lastModifiedFormatted: "Jul 19, 2026", + cli: ["claude"], + }, + ]; + } + + it("renders one folder row per profile and a leaf per channel", () => { + render(); + expect(screen.getByRole("button", { name: "Collapse hermes" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse default" })).toBeInTheDocument(); + // A hyphenated profile stays ONE segment — not "bangalore/weather". + expect(screen.getByRole("button", { name: "Collapse bangalore-weather" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "slack" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "telegram" })).toBeInTheDocument(); + }); + + it("links leaves to the unchanged /project/ route", () => { + render(); + expect(screen.getByRole("link", { name: "slack" })).toHaveAttribute( + "href", + "/project/hermes-default-slack", + ); + }); + + it("keeps filesystem projects as flat rows alongside the tree", () => { + render(); + expect(screen.getByRole("link", { name: "/home/u/app" })).toHaveAttribute( + "href", + "/project/-home-u-app", + ); + }); + + it("rolls session counts up onto folder rows", () => { + render(); + expect(screen.getByText("11 sessions")).toBeInTheDocument(); // 3 + 8 at the hermes root + expect(screen.getByText("3 sessions")).toBeInTheDocument(); // default + expect(screen.getByText("8 sessions")).toBeInTheDocument(); // bangalore-weather + }); + + it("is expanded by default and collapses a subtree on click", async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByRole("link", { name: "slack" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Collapse hermes" })); + + expect(screen.queryByRole("link", { name: "slack" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "telegram" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand hermes" })).toBeInTheDocument(); + // The flat project is untouched by a gateway collapse. + expect(screen.getByRole("link", { name: "/home/u/app" })).toBeInTheDocument(); + }); + + it("collapses only the clicked branch, leaving siblings open", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Collapse default" })); + expect(screen.queryByRole("link", { name: "slack" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "telegram" })).toBeInTheDocument(); + }); + + it("force-expands so a search match is never hidden inside a collapsed folder", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Collapse hermes" })); + expect(screen.queryByRole("link", { name: "slack" })).not.toBeInTheDocument(); + + const input = screen.getByPlaceholderText("Enter keyword and press Enter"); + await user.type(input, "slack{Enter}"); + + expect(screen.getByRole("link", { name: "slack" })).toBeInTheDocument(); + }); + + it("still counts PROJECTS, not tree rows, in the results summary", () => { + render(); + expect(screen.getByText(/Showing 1-3 of 3 projects/)).toBeInTheDocument(); + }); + + it("shows no expand/collapse control when there are no gateway projects", () => { + render(); + expect(screen.queryByRole("button", { name: /Collapse all|Expand all/ })).not.toBeInTheDocument(); + }); + + // ── Regressions caught by the adversarial review of this feature ── + + it("rolls up the TRUE session total even when a profile spans a page boundary", () => { + // The tree used to be built from the paginated slice, so one profile's + // channels straddling page 25 rendered the root twice, each time summing + // only that page's leaves — the real total appeared nowhere. + const many: ProjectFolder[] = [ + ...makeFolders(24), // newest, fill page 1 + { + name: "hermes-default-slack", + path: "hermes:default:slack", + isDirectory: true, + lastModified: new Date("2020-01-03T00:00:00Z"), + cli: ["hermes"], + sessionCount: 10, + }, + { + name: "hermes-default-cron", + path: "hermes:default:cron", + isDirectory: true, + lastModified: new Date("2020-01-02T00:00:00Z"), + cli: ["hermes"], + sessionCount: 20, + }, + { + name: "hermes-default-cli", + path: "hermes:default:cli", + isDirectory: true, + lastModified: new Date("2020-01-01T00:00:00Z"), + cli: ["hermes"], + sessionCount: 30, + }, + ]; + render(); + // One `hermes` root, reporting all 60 — not 10 here and 50 on page 2. + expect(screen.getAllByRole("button", { name: "Collapse hermes" })).toHaveLength(1); + expect(screen.getAllByText("60 sessions")).toHaveLength(2); // root + profile + }); + + it("still reports a project range, counting projects rather than tree rows", () => { + // 24 filesystem projects + 3 hermes channels = 27 projects, but only 25 + // top-level rows (the 3 channels fold into one `hermes` entry). + const many: ProjectFolder[] = [ + ...makeFolders(24), + ...["slack", "cron", "cli"].map((source, i) => ({ + name: `hermes-default-${source}`, + path: `hermes:default:${source}`, + isDirectory: true, + lastModified: new Date(2020, 0, i + 1), + cli: ["hermes" as const], + sessionCount: 1, + })), + ]; + render(); + expect(screen.getByText(/Showing 1-27 of 27 projects/)).toBeInTheDocument(); + }); + + it("keeps collapse state for folders outside the current filter when using Collapse all", async () => { + // `toggleAll` used to REPLACE the persisted set with the current view's + // keys, silently dropping collapses made under another filter or page. + const user = userEvent.setup(); + const folders: ProjectFolder[] = [ + { + name: "hermes-default-slack", + path: "hermes:default:slack", + isDirectory: true, + lastModified: new Date("2026-07-20T00:00:00Z"), + cli: ["hermes"], + sessionCount: 1, + }, + { + name: "openclaw-bot-telegram", + path: "openclaw:bot:telegram", + isDirectory: true, + lastModified: new Date("2026-07-19T00:00:00Z"), + cli: ["openclaw"], + sessionCount: 1, + }, + ]; + render(); + + await user.click(screen.getByRole("button", { name: "Collapse hermes" })); + expect(JSON.parse(window.localStorage.getItem("failproofai.projects.collapsed")!)).toEqual([ + "hermes", + ]); + + await user.selectOptions(screen.getByLabelText("Filter by CLI"), "openclaw"); + await user.click(screen.getByRole("button", { name: /Collapse all/ })); + + const stored: string[] = JSON.parse( + window.localStorage.getItem("failproofai.projects.collapsed")!, + ); + expect(stored).toContain("hermes"); // the off-filter collapse survives + expect(stored).toContain("openclaw"); + }); + + it("flips the bulk toggle label once everything on screen is collapsed", async () => { + // The label was computed over every collapsible key in the page's tree, + // including ones hidden inside an already-collapsed parent — so it kept + // saying "Collapse all" while clicking it changed nothing visible. + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Collapse hermes" })); + expect(screen.getByRole("button", { name: /Expand all/ })).toBeInTheDocument(); + }); + + it("makes folder toggles inert during a search instead of silently flipping saved state", async () => { + // Search force-expands, so a toggle could only flip hidden state: no visual + // feedback, and the collapse silently inverted once the search cleared. + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Collapse hermes" })); + + await user.type( + screen.getByPlaceholderText("Enter keyword and press Enter"), + "hermes{Enter}", + ); + // Force-expanded, and no longer offering a button to click. + expect(screen.getByRole("link", { name: "slack" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Collapse hermes|Expand hermes/ })).not.toBeInTheDocument(); + + // The saved collapse is untouched by the search. + expect(JSON.parse(window.localStorage.getItem("failproofai.projects.collapsed")!)).toEqual([ + "hermes", + ]); + }); + + // The folder icon lives in its own , so indenting only the name cell left + // every child's icon at the same x as its parent's — the rows read as a flat + // list with ragged text instead of a hierarchy. Icon and label must shift + // together, and each level must start further right than the one above it. + it("indents the icon and the label together, one step per depth", () => { + const folders: ProjectFolder[] = [ + { + name: "hermes-default-slack", + path: "hermes:default:slack", + isDirectory: true, + lastModified: new Date("2026-07-20T00:00:00Z"), + lastModifiedFormatted: "Jul 20, 2026", + cli: ["hermes"], + sessionCount: 3, + }, + ]; + const { container } = render(); + + // hermes (depth 0) → default (depth 1) → slack (depth 2) + const padOf = (text: string) => { + const row = [...container.querySelectorAll("tr")].find((r) => r.textContent?.includes(text))!; + const cells = [...row.querySelectorAll("td")]; + const iconPad = (cells[0].firstElementChild as HTMLElement).style.paddingLeft; + const namePad = (cells[1].firstElementChild as HTMLElement).style.paddingLeft; + // Icon and name must agree, or the row splits into two ragged columns. + expect(iconPad).toBe(namePad); + return parseFloat(iconPad || "0"); + }; + + const root = padOf("hermes"); + const profile = padOf("default"); + const channel = padOf("slack"); + + expect(root).toBe(0); + expect(profile).toBeGreaterThan(root); + expect(channel).toBeGreaterThan(profile); + }); +}); diff --git a/__tests__/hooks/claude-prune-malformed-settings.test.ts b/__tests__/hooks/claude-prune-malformed-settings.test.ts new file mode 100644 index 00000000..f91768c9 --- /dev/null +++ b/__tests__/hooks/claude-prune-malformed-settings.test.ts @@ -0,0 +1,97 @@ +/** + * `claudeCode.writeHookEntries` prunes our own stale entries from events we no + * longer install. That loop walks EVERY key in `settings.hooks`, including keys + * failproofai never wrote — a newer Claude event, another tool's entry, a typo — + * so their values are unvalidated input. + * + * A non-array value there used to throw (`{} is not iterable`), and the throw + * propagates out of `installHooks` AFTER the selected policies have already been + * written to policies-config.json. The user is told their policies are enabled + * while settings.json received no hook at all: silent non-enforcement, the exact + * failure mode the pruning was added to fix. + * + * Foreign entries must also survive the pass intact — pruning is for our own + * marked hooks, not a cleanup of the user's file. + */ +import { describe, it, expect } from "vitest"; +import { claudeCode } from "../../src/hooks/integrations"; +import { FAILPROOFAI_HOOK_MARKER } from "../../src/hooks/types"; + +type Settings = Record; + +const ourHook = (event: string) => ({ + type: "command", + command: `npx -y failproofai --hook ${event}`, + timeout: 60, + [FAILPROOFAI_HOOK_MARKER]: true, +}); + +function countOurHooks(settings: Settings): number { + const hooks = (settings.hooks ?? {}) as Record; + let n = 0; + for (const v of Object.values(hooks)) { + if (!Array.isArray(v)) continue; + for (const m of v) { + if (!m || !Array.isArray((m as { hooks?: unknown[] }).hooks)) continue; + n += (m as { hooks: Record[] }).hooks.filter( + (h) => h?.[FAILPROOFAI_HOOK_MARKER] === true, + ).length; + } + } + return n; +} + +describe("claudeCode.writeHookEntries with hand-edited settings", () => { + it("does not throw on a non-array value under a key we never manage", () => { + const settings: Settings = { + hooks: { + SomeToolsOwnKey: { not: "an array" }, + AnotherKey: "oops", + PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "THEIRS" }] }], + }, + }; + + expect(() => claudeCode.writeHookEntries(settings, "npx -y failproofai", "project")).not.toThrow(); + expect(countOurHooks(settings)).toBeGreaterThan(0); + }); + + it("leaves malformed and foreign entries exactly as found", () => { + const settings: Settings = { + hooks: { + SomeToolsOwnKey: { not: "an array" }, + NoHooksKey: [{ matcher: "*" }], + ForeignEvent: [{ hooks: [{ type: "command", command: "THIRD-PARTY-ONLY" }] }], + }, + }; + + claudeCode.writeHookEntries(settings, "npx -y failproofai", "project"); + + const hooks = settings.hooks as Record; + expect(hooks.SomeToolsOwnKey).toEqual({ not: "an array" }); + expect(hooks.NoHooksKey).toEqual([{ matcher: "*" }]); + expect(hooks.ForeignEvent).toEqual([ + { hooks: [{ type: "command", command: "THIRD-PARTY-ONLY" }] }, + ]); + }); + + it("still prunes our own stale entry, keeping a co-located foreign hook", () => { + const settings: Settings = { + hooks: { + // WorktreeCreate is deliberately not installed any more. + WorktreeCreate: [ + { hooks: [ourHook("WorktreeCreate"), { type: "command", command: "THEIRS" }] }, + ], + StaleFailproofaiOnly: [{ hooks: [ourHook("StaleFailproofaiOnly")] }], + }, + }; + + claudeCode.writeHookEntries(settings, "npx -y failproofai", "project"); + + const hooks = settings.hooks as Record; + expect(hooks.WorktreeCreate).toEqual([ + { hooks: [{ type: "command", command: "THEIRS" }] }, + ]); + // Nothing left but ours -> the key goes away entirely. + expect("StaleFailproofaiOnly" in hooks).toBe(false); + }); +}); diff --git a/__tests__/hooks/convention-dir-dedup.test.ts b/__tests__/hooks/convention-dir-dedup.test.ts new file mode 100644 index 00000000..8fc0a55e --- /dev/null +++ b/__tests__/hooks/convention-dir-dedup.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +/** + * When the project root IS the home directory — a normal setup for a gateway, + * and the default when the CLI is run from `~` — project and user convention + * discovery resolve to the SAME directory. Loading both lists registers every + * hook twice, because `customPolicies.add` is an unconditional push. Every + * policy then fires twice per event: a counting policy double-counts and trips + * its ceiling at half the real number. + * + * This was masked, not prevented, by the runtime. Bun caches dynamic imports by + * resolved path and ignores the `?v=` cache-buster, so the second import was a + * no-op; Node honours the query and re-executes. The shipped binary runs under + * Bun and this suite runs under Node — so the bug was invisible from the + * production side and would have fired here. These tests run on the side that + * can actually see it. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadAllCustomHooks } from "@/src/hooks/custom-hooks-loader"; +import { clearCustomHooks } from "@/src/hooks/custom-hooks-registry"; + +const SRC = ` + import { customPolicies, allow } from "failproofai"; + customPolicies.add({ + name: "dedup-probe", + description: "test policy", + match: { events: ["PreToolUse"] }, + fn: async () => allow(), + }); +`; + +describe("convention discovery deduplicates overlapping directories", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fp-dedup-")); + mkdirSync(join(home, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(home, ".failproofai", "policies", "probe-policies.mjs"), SRC, "utf8"); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + clearCustomHooks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + clearCustomHooks(); + rmSync(home, { recursive: true, force: true }); + }); + + it("registers each hook once when project and user dirs are the same", async () => { + const result = await loadAllCustomHooks(undefined, { sessionCwd: home }); + + expect(result.hooks).toHaveLength(1); + expect(result.hooks.map((h) => h.name)).toEqual(["dedup-probe"]); + }); + + it("reports the file once, not once per scope", async () => { + const result = await loadAllCustomHooks(undefined, { sessionCwd: home }); + + const entries = result.conventionSources.filter((s) => s.file === "probe-policies.mjs"); + expect(entries).toHaveLength(1); + }); +}); diff --git a/__tests__/hooks/convention-policy-config-sync.test.ts b/__tests__/hooks/convention-policy-config-sync.test.ts new file mode 100644 index 00000000..eaaa3a28 --- /dev/null +++ b/__tests__/hooks/convention-policy-config-sync.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +// +// `policies-config.json` recorded enabled builtins and an explicit +// `customPoliciesPath`, but nothing at all for policies dropped into +// `.failproofai/policies/` — so a user who installed four convention policies +// saw an empty-looking config and reasonably concluded discovery was broken. +// +// `failproofai policies` now mirrors what it lists into the config. The mirror +// is descriptive only: enforcement still discovers from the filesystem, so +// these tests also pin that a stale or absent record never gates a policy. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { listHooks } from "@/src/hooks/manager"; +import { syncConventionPolicies } from "@/src/hooks/hooks-config"; + +function policySource(hookName: string): string { + return ` + import { customPolicies, allow } from "failproofai"; + customPolicies.add({ + name: ${JSON.stringify(hookName)}, + description: "test policy", + match: { events: ["PreToolUse"] }, + fn: async () => allow(), + }); + `; +} + +describe("convention policies are mirrored into policies-config.json", () => { + let home: string; + let logSpy: ReturnType; + + const configPath = () => join(home, ".failproofai", "policies-config.json"); + const readConfig = () => JSON.parse(readFileSync(configPath(), "utf8")); + + function seed(files: Record) { + const dir = join(home, ".failproofai", "policies"); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body, "utf8"); + } + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fp-sync-")); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + }); + + it("records each discovered file and the hooks it registered", async () => { + seed({ "team-policies.mjs": policySource("team-rule") }); + writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + + await listHooks(home); + + expect(readConfig().conventionPolicies).toEqual([ + { file: "team-policies.mjs", hooks: ["team-rule"] }, + ]); + }); + + it("preserves the keys that were already there", async () => { + seed({ "team-policies.mjs": policySource("team-rule") }); + writeFileSync( + configPath(), + JSON.stringify({ enabledPolicies: ["warn-schema-alteration"], policyParams: { foo: { a: 1 } } }), + "utf8", + ); + + await listHooks(home); + + const config = readConfig(); + expect(config.enabledPolicies).toEqual(["warn-schema-alteration"]); + expect(config.policyParams).toEqual({ foo: { a: 1 } }); + expect(config.conventionPolicies).toHaveLength(1); + }); + + it("drops a file from the record once it is deleted", async () => { + seed({ + "team-policies.mjs": policySource("team-rule"), + "extra-policies.mjs": policySource("extra-rule"), + }); + writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + await listHooks(home); + expect(readConfig().conventionPolicies).toHaveLength(2); + + rmSync(join(home, ".failproofai", "policies", "extra-policies.mjs")); + await listHooks(home); + + // Wholesale replace, not merge — a stale entry would claim a policy is + // installed when the file is gone. + expect(readConfig().conventionPolicies).toEqual([ + { file: "team-policies.mjs", hooks: ["team-rule"] }, + ]); + }); + + it("does not rewrite the file when nothing changed", async () => { + seed({ "team-policies.mjs": policySource("team-rule") }); + writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + await listHooks(home); + const firstWrite = statSync(configPath()).mtimeMs; + + await new Promise((r) => setTimeout(r, 10)); + await listHooks(home); + + expect(statSync(configPath()).mtimeMs).toBe(firstWrite); + }); + + it("does not create a config file just to record an empty list", () => { + const empty = mkdtempSync(join(tmpdir(), "fp-sync-empty-")); + try { + const wrote = syncConventionPolicies([], "project", empty); + expect(wrote).toBe(false); + expect(existsSync(join(empty, ".failproofai", "policies-config.json"))).toBe(false); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); + + it("removes the key entirely when the last policy file goes away", async () => { + seed({ "team-policies.mjs": policySource("team-rule") }); + writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + await listHooks(home); + expect(readConfig().conventionPolicies).toBeDefined(); + + rmSync(join(home, ".failproofai", "policies", "team-policies.mjs")); + await listHooks(home); + + expect("conventionPolicies" in readConfig()).toBe(false); + }); + + // `.failproofai/policies-config.json` is routinely committed — this repo + // tracks its own — so persisting the record at project scope would make a + // plain `failproofai policies` dirty the working tree and put a spurious diff + // in front of every contributor. A read command must not do that. + it("never writes the record into a project config", async () => { + const project = mkdtempSync(join(tmpdir(), "fp-proj-")); + try { + const dir = join(project, ".failproofai", "policies"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "proj-policies.mjs"), policySource("proj-rule"), "utf8"); + const projConfig = join(project, ".failproofai", "policies-config.json"); + const original = JSON.stringify({ enabledPolicies: ["block-sudo"] }, null, 2) + "\n"; + writeFileSync(projConfig, original, "utf8"); + + await listHooks(project); + + expect(readFileSync(projConfig, "utf8")).toBe(original); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + // DATA LOSS GUARD. `readScopedHooksConfig` fails soft, returning + // {enabledPolicies: []} when the file will not parse — right for the hook + // path, fatal here. Writing that default back destroys everything the user + // had. One stray comma in a hand-edited config plus a `failproofai policies` + // run used to wipe every enabled policy, from a read-only command. + it("never overwrites a config file that does not parse", async () => { + seed({ "team-policies.mjs": policySource("team-rule") }); + const malformed = '{\n "enabledPolicies": ["block-sudo"],\n OOPS\n}\n'; + writeFileSync(configPath(), malformed, "utf8"); + + await listHooks(home); + + expect(readFileSync(configPath(), "utf8")).toBe(malformed); + }); + + it("reports not-written for an unparseable config", () => { + const dir = mkdtempSync(join(tmpdir(), "fp-bad-")); + try { + mkdirSync(join(dir, ".failproofai"), { recursive: true }); + writeFileSync(join(dir, ".failproofai", "policies-config.json"), "{ nope", "utf8"); + const wrote = syncConventionPolicies( + [{ file: "x-policies.mjs", hooks: ["x"] }], + "project", + dir, + ); + expect(wrote).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/hooks/dogfood-configs.test.ts b/__tests__/hooks/dogfood-configs.test.ts index 39a344e8..f0b07c15 100644 --- a/__tests__/hooks/dogfood-configs.test.ts +++ b/__tests__/hooks/dogfood-configs.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { CLAUDE_INSTALL_EVENT_TYPES } from "@/src/hooks/types"; const ROOT = process.cwd(); const LAUNCHER = "scripts/dev-hook.mjs"; @@ -55,7 +56,7 @@ function commandsIn(file: string): string[] { const STOP_CLASS = new Set(["Stop", "StopFailure", "SubagentStop", "stop", "subagentStop"]); const CONFIGS = [ - { file: ".claude/settings.json", cli: null, count: 26 }, + { file: ".claude/settings.json", cli: null, count: 25 }, { file: ".codex/hooks.json", cli: "codex", count: 6 }, { file: ".github/hooks/failproofai.json", cli: "copilot", count: 12 }, // 6 events × bash+powershell { file: ".cursor/hooks.json", cli: "cursor", count: 6 }, @@ -184,3 +185,26 @@ describe("repo-wide invariants", () => { expect(dockerfile).toMatch(/bun/i); }); }); + +// A bare event COUNT cannot catch the failure that matters: registering an +// event failproofai no longer installs. `WorktreeCreate` sat in this file after +// it was dropped from CLAUDE_INSTALL_EVENT_TYPES, so `claude --worktree` was +// broken in this repo while the count assertion stayed green — Claude uses that +// event as a worktree-PATH PROVIDER and our silent-on-allow hook makes it abort. +describe("dogfood .claude/settings.json only registers installable events", () => { + it("registers no event outside CLAUDE_INSTALL_EVENT_TYPES", () => { + const settings = JSON.parse( + readFileSync(resolve(ROOT, ".claude/settings.json"), "utf-8"), + ) as { hooks: Record }; + const installable = new Set(CLAUDE_INSTALL_EVENT_TYPES); + const extra = Object.keys(settings.hooks).filter((e) => !installable.has(e)); + expect(extra).toEqual([]); + }); + + it("never registers WorktreeCreate", () => { + const settings = JSON.parse( + readFileSync(resolve(ROOT, ".claude/settings.json"), "utf-8"), + ) as { hooks: Record }; + expect(settings.hooks.WorktreeCreate).toBeUndefined(); + }); +}); diff --git a/__tests__/hooks/enforcement-capability.test.ts b/__tests__/hooks/enforcement-capability.test.ts new file mode 100644 index 00000000..79b7bbfa --- /dev/null +++ b/__tests__/hooks/enforcement-capability.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +/** + * Guards the capability matrix that the activity log renders from. + * + * The matrix used to be prose in CLAUDE.md, and prose drifted: Hermes + * `subagent_stop` was documented "✅ block" while upstream discarded its return, + * so customer policies on that event enforced nothing for months. A table with + * tests behind it cannot rot the same way. + */ +import { describe, it, expect } from "vitest"; +import { + ENFORCEMENT_CAPABILITY, + enforcementFor, +} from "@/src/hooks/enforcement-capability"; +import { INTEGRATION_TYPES, HOOK_EVENT_TYPES } from "@/src/hooks/types"; + +describe("ENFORCEMENT_CAPABILITY", () => { + it("keys every integration", () => { + for (const cli of INTEGRATION_TYPES) { + expect(ENFORCEMENT_CAPABILITY[cli]).toBeDefined(); + } + }); + + it("uses only canonical event names", () => { + const canonical = new Set(HOOK_EVENT_TYPES); + for (const [cli, events] of Object.entries(ENFORCEMENT_CAPABILITY)) { + for (const event of Object.keys(events)) { + expect(canonical.has(event), `${cli}.${event} is not a canonical event`).toBe(true); + } + } + }); + + it("uses only block or observe — never a hedge value", () => { + for (const events of Object.values(ENFORCEMENT_CAPABILITY)) { + for (const capability of Object.values(events)) { + expect(["block", "observe"]).toContain(capability); + } + } + }); + + // The load-bearing property. Absent must mean "we did not verify this", so a + // caller can stay silent. If lookup ever defaulted to a value, the UI would + // assert a capability nobody established — the exact failure being fixed. + it("returns undefined for an unverified pair rather than guessing", () => { + expect(enforcementFor("goose", "Stop")).toBeUndefined(); + expect(enforcementFor("hermes", "Stop")).toBeUndefined(); + expect(enforcementFor(undefined, "PreToolUse")).toBeUndefined(); + expect(enforcementFor("claude", undefined)).toBeUndefined(); + expect(enforcementFor("claude", "NotAnEvent")).toBeUndefined(); + }); + + it("records the findings that motivated this file", () => { + // Every CLI we install a tool gate for blocks on PreToolUse — the one + // capability the daily suite exercises. + for (const cli of INTEGRATION_TYPES) { + const pre = ENFORCEMENT_CAPABILITY[cli].PreToolUse; + if (pre !== undefined) expect(pre).toBe("block"); + } + + // Hermes: only pre_tool_call is gated in upstream's _parse_response among + // the events we install. (pre_verify is also gated in upstream, but we do + // not install it, so no Stop event fires.) + expect(enforcementFor("hermes", "PreToolUse")).toBe("block"); + expect(enforcementFor("hermes", "SessionEnd")).toBe("observe"); + // Documented as a working gate for months; upstream discards the return. + expect(enforcementFor("hermes", "SubagentStop")).toBe("observe"); + + // OpenClaw: three real gates, five void hooks. + expect(enforcementFor("openclaw", "Stop")).toBe("block"); + expect(enforcementFor("openclaw", "SubagentStop")).toBe("observe"); + + // Antigravity: PostToolHookResult is an empty proto message, so our deny is + // not merely ignored — protojson rejects it and the hook call errors. + expect(enforcementFor("antigravity", "PostToolUse")).toBe("observe"); + }); + + it("never claims WorktreeCreate as a capability", () => { + // It is a path provider, not a gate: with no stdout an ALLOW aborts + // worktree creation exactly like a deny. Degenerate, so it is unclassified + // and we no longer install it. + for (const events of Object.values(ENFORCEMENT_CAPABILITY)) { + expect(events.WorktreeCreate).toBeUndefined(); + } + }); +}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 32b9c11d..072020aa 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -24,6 +24,9 @@ vi.mock("../../src/hooks/policy-evaluator", () => ({ vi.mock("../../src/hooks/policy-registry", () => ({ clearPolicies: vi.fn(), registerPolicy: vi.fn(), + // handler.ts reads this back after evaluation to record which policies + // actually ran (activity `matchedPolicies`), so the mock has to provide it. + getPoliciesForEvent: vi.fn(() => []), })); vi.mock("../../src/hooks/custom-hooks-loader", () => ({ diff --git a/__tests__/hooks/inert-deny-shapes.test.ts b/__tests__/hooks/inert-deny-shapes.test.ts new file mode 100644 index 00000000..6b3cf8ab --- /dev/null +++ b/__tests__/hooks/inert-deny-shapes.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +/** + * Five events where failproofai emitted a deny the CLI does not read. + * + * Each was a silent non-enforcement: the policy ran, the evaluator produced a + * verdict, the activity store recorded `decision: "deny"` and telemetry counted + * one — and the CLI submitted the prompt or ran the command anyway, because the + * shape we sent matched nothing it parses. The CLI could block on every one of + * these; the gap was ours, not the vendor's. + * + * Shapes verified against the shipped vendor builds (see + * src/hooks/enforcement-capability.ts for byte offsets) and, for Pi, against + * the installed package's own .d.ts. + */ +import { describe, it, expect } from "vitest"; +import { evaluatePolicies } from "@/src/hooks/policy-evaluator"; +import { registerPolicy, clearPolicies } from "@/src/hooks/policy-registry"; +import { deny } from "@/src/hooks/policy-helpers"; +import type { SessionMetadata, IntegrationType } from "@/src/hooks/types"; + +function session(cli: IntegrationType): SessionMetadata { + return { sessionId: "t", transcriptPath: "/dev/null", cwd: "/tmp", cli }; +} + +async function denyOn(cli: IntegrationType, eventType: string, payload = {}) { + clearPolicies(); + registerPolicy("test/blocker", "blocks", async () => deny("nope"), {}, 0); + const result = await evaluatePolicies( + eventType as Parameters[0], + payload, + session(cli), + ); + return { result, stdout: result.stdout ? JSON.parse(result.stdout) : null }; +} + +describe("deny shapes the CLI actually reads", () => { + it("copilot UserPromptSubmit uses {decision:'block'} at exit 0, not exit 2", async () => { + // Copilot logs exit 2 as `Hook command exited with code 2 (warning)` for + // EVERY event — it is never a deny channel there. + const { result, stdout } = await denyOn("copilot", "UserPromptSubmit", { prompt: "hi" }); + expect(result.exitCode).toBe(0); + expect(stdout.decision).toBe("block"); + expect(typeof stdout.reason).toBe("string"); + expect(stdout.reason.length).toBeGreaterThan(0); + }); + + it("copilot PermissionRequest uses a FLAT {behavior,message}", async () => { + // The Codex-shaped nested hookSpecificOutput.decision normalizes to `{}` on + // copilot, so the permission prompt proceeded as if no policy existed. + const { result, stdout } = await denyOn("copilot", "PermissionRequest", { tool_name: "Bash" }); + expect(result.exitCode).toBe(0); + expect(stdout.behavior).toBe("deny"); + expect(typeof stdout.message).toBe("string"); + expect(stdout.hookSpecificOutput).toBeUndefined(); + }); + + it("cursor UserPromptSubmit uses {continue:false}, not {permission:'deny'}", async () => { + // beforeSubmitPrompt's only block key is `continue === false`; an object + // with unknown keys validates and is dropped. + const { result, stdout } = await denyOn("cursor", "UserPromptSubmit", { prompt: "hi" }); + expect(result.exitCode).toBe(0); + expect(stdout.continue).toBe(false); + expect(typeof stdout.user_message).toBe("string"); + expect(stdout.permission).toBeUndefined(); + }); + + it("cursor tool events still use the flat permission shape", async () => { + // Regression guard: the UserPromptSubmit branch must not swallow the tool + // path, which genuinely does read `permission`. + const { stdout } = await denyOn("cursor", "PreToolUse", { tool_name: "Bash" }); + expect(stdout.permission).toBe("deny"); + expect(stdout.continue).toBeUndefined(); + }); + + it("copilot Stop keeps its force-retry shape", async () => { + const { stdout } = await denyOn("copilot", "Stop"); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).toContain("MANDATORY ACTION REQUIRED"); + }); + + // Hermes emits the same block shape on every installed event; only + // pre_tool_call is read by upstream. We do not install pre_verify, so there + // is no Stop branch to special-case. + it("hermes events carry the plain blocked message", async () => { + const { stdout } = await denyOn("hermes", "PreToolUse", { tool_name: "Bash" }); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).not.toContain("MANDATORY ACTION REQUIRED"); + }); +}); diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 8f247a2b..d1aac972 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -28,6 +28,8 @@ import { goose, getIntegration, listIntegrations, + settingsPathsFor, + unhookedHermesProfiles, } from "../../src/hooks/integrations"; import { CODEX_HOOK_EVENT_TYPES, @@ -46,6 +48,7 @@ import { ANTIGRAVITY_HOOK_EVENT_TYPES, GOOSE_HOOK_EVENT_TYPES, HOOK_EVENT_TYPES, + CLAUDE_INSTALL_EVENT_TYPES, FAILPROOFAI_HOOK_MARKER, type CodexHookEventType, type CursorHookEventType, @@ -164,13 +167,16 @@ describe("Claude Code integration", () => { expect(entry.command).toBe("npx -y failproofai --hook PreToolUse"); }); - it("writeHookEntries adds a matcher per HOOK_EVENT_TYPES event", () => { + // Installed events are HOOK_EVENT_TYPES minus WorktreeCreate, which is a + // worktree-path provider rather than a gate — see the dedicated describe below. + it("writeHookEntries adds a matcher per installed event", () => { const settings: Record = {}; claudeCode.writeHookEntries(settings, "/usr/bin/failproofai", "user"); const hooks = settings.hooks as Record; - for (const eventType of HOOK_EVENT_TYPES) { + for (const eventType of CLAUDE_INSTALL_EVENT_TYPES) { expect(hooks[eventType]).toBeDefined(); } + expect(Object.keys(hooks)).toHaveLength(CLAUDE_INSTALL_EVENT_TYPES.length); }); it("re-running writeHookEntries is idempotent (replaces, doesn't duplicate)", () => { @@ -189,7 +195,7 @@ describe("Claude Code integration", () => { claudeCode.writeSettings(settingsPath, settings); const removed = claudeCode.removeHooksFromFile(settingsPath); - expect(removed).toBe(HOOK_EVENT_TYPES.length); + expect(removed).toBe(CLAUDE_INSTALL_EVENT_TYPES.length); const after = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; expect(after.hooks).toBeUndefined(); @@ -547,15 +553,29 @@ describe("Hermes integration", () => { // the per-test tempDir so getSettingsPath / hooksInstalledInSettings operate // on a throwaway file instead of the developer's real home. let origHome: string | undefined; + let origHermesHome: string | undefined; beforeEach(() => { origHome = process.env.HOME; process.env.HOME = tempDir; + // HERMES_HOME wins over HOME in profile discovery — clear it so a developer + // with a profile-scoped shell doesn't get their real config.yaml touched. + origHermesHome = process.env.HERMES_HOME; + delete process.env.HERMES_HOME; }); afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; + if (origHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = origHermesHome; }); + /** Create `~/.hermes/profiles//` for each name. */ + function makeProfiles(...names: string[]): void { + for (const name of names) { + mkdirSync(resolve(tempDir, ".hermes", "profiles", name), { recursive: true }); + } + } + it("getSettingsPath is user-scope ~/.hermes/config.yaml regardless of scope/cwd", () => { expect(hermes.getSettingsPath("user")).toBe(resolve(tempDir, ".hermes", "config.yaml")); // scope/cwd are ignored — Hermes has no project config. @@ -677,6 +697,71 @@ describe("Hermes integration", () => { hermes.writeSettings(path, settings); expect(hermes.hooksInstalledInSettings("user")).toBe(true); }); + + // ── Profiles ── + // + // Every Hermes profile is a separate home dir with its OWN config.yaml, so an + // install that writes only ~/.hermes/config.yaml leaves the others running + // unhooked — silently, since Hermes never reports a missing hook. + + it("getSettingsPaths returns one config.yaml per profile, root first", () => { + makeProfiles("work", "my-bot"); + expect(hermes.getSettingsPaths!("user")).toEqual([ + resolve(tempDir, ".hermes", "config.yaml"), + resolve(tempDir, ".hermes", "profiles", "my-bot", "config.yaml"), + resolve(tempDir, ".hermes", "profiles", "work", "config.yaml"), + ]); + }); + + it("settingsPathsFor falls back to the single path for non-profile integrations", () => { + expect(settingsPathsFor(claudeCode, "user")).toEqual([claudeCode.getSettingsPath("user")]); + expect(settingsPathsFor(hermes, "user")).toEqual(hermes.getSettingsPaths!("user")); + }); + + it("hooksInstalledInSettings is false until EVERY profile is hooked", () => { + makeProfiles("work"); + const [rootPath, workPath] = settingsPathsFor(hermes, "user"); + + const rootSettings = hermes.readSettings(rootPath); + hermes.writeHookEntries(rootSettings, "/usr/bin/failproofai", "user"); + hermes.writeSettings(rootPath, rootSettings); + // Root hooked, `work` still bare → the gateway is only partly enforced. + expect(hermes.hooksInstalledInSettings("user")).toBe(false); + expect(unhookedHermesProfiles()).toEqual(["work"]); + + const workSettings = hermes.readSettings(workPath); + hermes.writeHookEntries(workSettings, "/usr/bin/failproofai", "user"); + hermes.writeSettings(workPath, workSettings); + expect(hermes.hooksInstalledInSettings("user")).toBe(true); + expect(unhookedHermesProfiles()).toEqual([]); + }); + + it("writes a real hooks block into a non-default profile's config.yaml", () => { + makeProfiles("work"); + const workPath = resolve(tempDir, ".hermes", "profiles", "work", "config.yaml"); + writeFileSync(workPath, "model: gpt-5\n"); + const settings = hermes.readSettings(workPath); + hermes.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + hermes.writeSettings(workPath, settings); + + const parsed = parse(readFileSync(workPath, "utf-8")) as Record; + const hooks = parsed.hooks as Record; + expect(Object.keys(hooks).sort()).toEqual([...HERMES_HOOK_EVENT_TYPES].sort()); + expect(hooks.pre_tool_call[0].command).toContain("--cli hermes"); + expect(parsed.hooks_auto_accept).toBe(true); + expect(parsed.model).toBe("gpt-5"); // operator key preserved + }); + + it("HERMES_HOME pointing AT a profile still covers every sibling profile", () => { + // The per-profile alias wrapper exports HERMES_HOME=/profiles/. + makeProfiles("work", "coder"); + process.env.HERMES_HOME = resolve(tempDir, ".hermes", "profiles", "work"); + expect(hermes.getSettingsPaths!("user")).toEqual([ + resolve(tempDir, ".hermes", "config.yaml"), + resolve(tempDir, ".hermes", "profiles", "coder", "config.yaml"), + resolve(tempDir, ".hermes", "profiles", "work", "config.yaml"), + ]); + }); }); describe("HERMES_EVENT_MAP", () => { @@ -688,8 +773,14 @@ describe("HERMES_EVENT_MAP", () => { expect(HERMES_EVENT_MAP.subagent_stop).toBe("SubagentStop"); }); - it("has NO Stop mapping — Hermes has no turn-end event", () => { + // Upstream DOES have a turn-end gate (`pre_verify`) — the long-standing claim + // that it has none was wrong. We deliberately do not install it, so no + // canonical Stop event fires for Hermes and the 5 require-*-before-stop + // builtins stay inapplicable there. This asserts the decision, not a + // limitation of the platform; see the note in src/hooks/types.ts. + it("emits no Stop mapping — pre_verify exists upstream but is not installed", () => { expect(Object.values(HERMES_EVENT_MAP)).not.toContain("Stop"); + expect(HERMES_HOOK_EVENT_TYPES).not.toContain("pre_verify"); }); it("HERMES_EVENT_MAP keys exactly match HERMES_HOOK_EVENT_TYPES", () => { @@ -1643,4 +1734,68 @@ describe("Goose integration", () => { expect(goose.hooksInstalledInSettings("project", tempDir)).toBe(true); }); + +}); + +// Claude's `WorktreeCreate` is not a permission gate — it is a worktree-PATH +// PROVIDER. Claude takes the stdout of the first hook that succeeds as the +// directory to create, and fails with "WorktreeCreate hook failed" when none +// supplies one. failproofai writes nothing to stdout on allow, correctly, by +// the contract every other event uses — so merely registering there broke +// `claude --worktree` and `/worktree` for every user, whatever any policy +// decided. No builtin matches the event (all 39 match only PreToolUse / +// PostToolUse / PermissionRequest / Stop), so not registering costs nothing. +describe("claudeCode — WorktreeCreate is never registered", () => { + it("omits WorktreeCreate from the events it installs", () => { + expect(claudeCode.eventTypes).not.toContain("WorktreeCreate"); + // Sanity: the real gates are still installed. + expect(claudeCode.eventTypes).toContain("PreToolUse"); + expect(claudeCode.eventTypes).toContain("Stop"); + }); + + it("does not write a WorktreeCreate hook", () => { + const settings: Record = {}; + claudeCode.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + const hooks = settings.hooks as Record; + expect(hooks.WorktreeCreate).toBeUndefined(); + expect(hooks.PreToolUse).toBeDefined(); + }); + + it("prunes our stale WorktreeCreate entry on reinstall", () => { + // A machine installed before the fix. Reinstalling must repair it — + // otherwise the broken entry survives forever and worktrees stay broken. + const settings: Record = { + hooks: { + WorktreeCreate: [ + { hooks: [{ type: "command", command: "old", __failproofai_hook__: true }] }, + ], + }, + }; + claudeCode.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + const hooks = settings.hooks as Record; + expect(hooks.WorktreeCreate).toBeUndefined(); + }); + + it("leaves someone else's WorktreeCreate hook alone", () => { + // We only own entries carrying our marker; a user's own worktree-path + // provider is the thing that makes the feature work and must survive. + const settings: Record = { + hooks: { + WorktreeCreate: [ + { + hooks: [ + { type: "command", command: "echo /tmp/wt" }, + { type: "command", command: "old", __failproofai_hook__: true }, + ], + }, + ], + }, + }; + claudeCode.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + const hooks = settings.hooks as Record> }>>; + expect(hooks.WorktreeCreate).toHaveLength(1); + expect(hooks.WorktreeCreate[0].hooks).toHaveLength(1); + expect(hooks.WorktreeCreate[0].hooks[0].command).toBe("echo /tmp/wt"); + }); }); + diff --git a/__tests__/hooks/list-convention-column.test.ts b/__tests__/hooks/list-convention-column.test.ts new file mode 100644 index 00000000..4b5e386e --- /dev/null +++ b/__tests__/hooks/list-convention-column.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node +// +// `failproofai policies` pads the convention-policy filename column to +// `nameColWidth`, which is sized to the longest BUILTIN policy name. A +// convention filename can be longer, and `padEnd` to a width below the string +// length is a no-op — so the hook count ran straight into the filename with no +// separator at all: +// +// ✓ enforce-bengaluru-event-links-policies.mjs1 hook(s): enforce-links-... +// +// Reported from a live install. These assert the gutter survives. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { listHooks } from "@/src/hooks/manager"; + +const LONG_NAME = "enforce-bengaluru-event-links-policies.mjs"; +const SHORT_NAME = "team-policies.mjs"; + +function policySource(hookName: string): string { + return ` + import { customPolicies, allow } from "failproofai"; + customPolicies.add({ + name: ${JSON.stringify(hookName)}, + description: "test policy", + match: { events: ["PreToolUse"] }, + fn: async () => allow(), + }); + `; +} + +describe("listHooks — convention policy column width", () => { + let tmp: string; + let emptyHome: string; + let lines: string[]; + let logSpy: ReturnType; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fp-list-")); + emptyHome = mkdtempSync(join(tmpdir(), "fp-list-home-")); + // Keep the developer's own user-scope policies out of the output. + vi.stubEnv("HOME", emptyHome); + vi.stubEnv("USERPROFILE", emptyHome); + lines = []; + logSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + rmSync(tmp, { recursive: true, force: true }); + rmSync(emptyHome, { recursive: true, force: true }); + }); + + function seed(files: Record) { + const dir = join(tmp, ".failproofai", "policies"); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body, "utf8"); + } + } + + it("keeps a gap between a long filename and its hook count", async () => { + seed({ [LONG_NAME]: policySource("enforce-links-in-bengaluru-events") }); + + await listHooks(tmp); + + const row = lines.find((l) => l.includes(LONG_NAME)); + expect(row).toBeDefined(); + // The regression: `...policies.mjs1 hook(s)` with no separator. + expect(row).not.toMatch(new RegExp(`${LONG_NAME.replace(/\./g, "\\.")}\\d`)); + expect(row).toMatch(/\.mjs\s+1 hook\(s\)/); + }); + + it("aligns short and long filenames to the same column", async () => { + seed({ + [LONG_NAME]: policySource("enforce-links-in-bengaluru-events"), + [SHORT_NAME]: policySource("team-rule"), + }); + + await listHooks(tmp); + + const columnOf = (filename: string) => { + const row = lines.find((l) => l.includes(filename))!; + return row.replace(/\[[0-9;]*m/g, "").indexOf("1 hook(s)"); + }; + + expect(columnOf(LONG_NAME)).toBeGreaterThan(0); + expect(columnOf(SHORT_NAME)).toBe(columnOf(LONG_NAME)); + }); + + // Running `failproofai policies` from $HOME makes the project and user + // convention directories the SAME path. The listing walked both, so every + // file was printed twice — and the second pass rendered them all as + // "failed to load", because the first pass had already imported the module + // and the ESM cache short-circuits `customPolicies.add`, so + // `loadCustomHooks` legitimately returns 0 hooks. Reported from a live + // install where four working policies all showed ✗. + it("lists a shared project/user directory once, without a phantom load failure", async () => { + seed({ [SHORT_NAME]: policySource("team-rule") }); + // cwd === HOME: both scopes resolve to /.failproofai/policies. + vi.stubEnv("HOME", tmp); + vi.stubEnv("USERPROFILE", tmp); + + await listHooks(tmp); + + const headers = lines.filter((l) => l.includes("Convention Policies")); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain("Project + User"); + + const rows = lines.filter((l) => l.includes(SHORT_NAME)); + expect(rows).toHaveLength(1); + expect(rows.join("\n")).not.toContain("failed to load"); + }); + + it("still lists both directories when they differ", async () => { + seed({ [SHORT_NAME]: policySource("team-rule") }); + const otherHome = mkdtempSync(join(tmpdir(), "fp-list-other-")); + vi.stubEnv("HOME", otherHome); + vi.stubEnv("USERPROFILE", otherHome); + try { + await listHooks(tmp); + const headers = lines.filter((l) => l.includes("Convention Policies")); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain("Project"); + expect(headers[0]).not.toContain("Project + User"); + } finally { + rmSync(otherHome, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/hooks/loader-path-dedup.test.ts b/__tests__/hooks/loader-path-dedup.test.ts new file mode 100644 index 00000000..441288a4 --- /dev/null +++ b/__tests__/hooks/loader-path-dedup.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +/** + * A policy file must be imported at most ONCE per `loadAllCustomHooks` call, + * however many discovery routes point at it. + * + * `convention-dir-dedup.test.ts` covers the project-dir/user-dir overlap. This + * covers the other one: an explicit `customPoliciesPath` that points INTO + * `.failproofai/policies/` at a file whose name also matches the convention, so + * step 1 and step 2 both load it. That is an ordinary setup, not a contrived + * one — `failproofai policies -i -c .failproofai/policies/my-policies.mjs` + * produces exactly it. + * + * `customPolicies.add` is an unconditional push, so the second import registers + * every hook again and the policy runs twice per event, doubling side effects + * and any counter it keeps. + * + * Node-only, which is why it needs pinning here: Bun caches dynamic imports by + * resolved path and ignores the `?v=` cache-buster, so the duplicate import is + * a silent no-op there, while Node — which runs the published `dist/cli.mjs` + * and this suite — honours the query and re-executes the module body. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadAllCustomHooks } from "@/src/hooks/custom-hooks-loader"; +import { clearCustomHooks } from "@/src/hooks/custom-hooks-registry"; + +const SRC = ` + import { customPolicies, allow } from "failproofai"; + customPolicies.add({ + name: "dedup-probe", + description: "test policy", + match: { events: ["PreToolUse"] }, + fn: async () => allow(), + }); +`; + +describe("loadAllCustomHooks deduplicates customPoliciesPath against convention files", () => { + let home: string; + let project: string; + + beforeEach(() => { + // Keep HOME isolated and distinct from the project root, so the user-scope + // pass is a genuinely separate directory and cannot mask the overlap. + home = mkdtempSync(join(tmpdir(), "fp-home-")); + project = mkdtempSync(join(tmpdir(), "fp-proj-")); + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + clearCustomHooks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + clearCustomHooks(); + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + }); + + it("registers the hook once when customPoliciesPath is also a convention file", async () => { + const file = join(project, ".failproofai", "policies", "shared-policies.mjs"); + writeFileSync(file, SRC, "utf8"); + + const result = await loadAllCustomHooks(file, { sessionCwd: project }); + + expect(result.hooks.filter((h) => h.name === "dedup-probe")).toHaveLength(1); + }); + + it("does not report it as a convention source once step 1 has loaded it", async () => { + const file = join(project, ".failproofai", "policies", "shared-policies.mjs"); + writeFileSync(file, SRC, "utf8"); + + const result = await loadAllCustomHooks(file, { sessionCwd: project }); + + expect(result.conventionSources.filter((s) => s.file === "shared-policies.mjs")).toHaveLength(0); + }); + + it("still loads a customPoliciesPath that is not a convention file", async () => { + const outside = join(project, "standalone.mjs"); + writeFileSync(outside, SRC, "utf8"); + + const result = await loadAllCustomHooks(outside, { sessionCwd: project }); + + expect(result.hooks.filter((h) => h.name === "dedup-probe")).toHaveLength(1); + }); + + it("still loads convention files when no customPoliciesPath is set", async () => { + const file = join(project, ".failproofai", "policies", "conv-policies.mjs"); + writeFileSync(file, SRC, "utf8"); + + const result = await loadAllCustomHooks(undefined, { sessionCwd: project }); + + expect(result.hooks.filter((h) => h.name === "dedup-probe")).toHaveLength(1); + expect(result.conventionSources.map((s) => s.file)).toContain("conv-policies.mjs"); + }); +}); diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index c10f5aa4..79bffaba 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -24,6 +24,10 @@ vi.mock("../../src/hooks/install-prompt", () => ({ vi.mock("../../src/hooks/hooks-config", () => ({ readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), + // listHooks resolves the project root by walking up to the nearest + // `.failproofai` marker, the same way enforcement does. The real function + // hits the filesystem; for these tests the cwd IS the project root. + findProjectConfigDir: vi.fn((start: string) => start), readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), writeHooksConfig: vi.fn(), readScopedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), @@ -65,7 +69,10 @@ describe("hooks/manager", () => { }); describe("installHooks", () => { - it("installs hooks for all 29 event types into empty settings", async () => { + // 28, not 29: WorktreeCreate is deliberately not installed — Claude uses it + // as a worktree-PATH PROVIDER (first hook's stdout becomes the directory), + // and our silent-on-allow contract broke `claude --worktree` for every user. + it("installs hooks for all 28 installed event types into empty settings", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue("{}"); @@ -77,7 +84,8 @@ describe("hooks/manager", () => { expect(path).toBe(USER_SETTINGS_PATH); const written = JSON.parse(content as string); - expect(Object.keys(written.hooks)).toHaveLength(29); + expect(Object.keys(written.hooks)).toHaveLength(28); + expect(written.hooks.WorktreeCreate).toBeUndefined(); for (const [eventType, matchers] of Object.entries(written.hooks)) { expect(matchers).toHaveLength(1); @@ -231,7 +239,7 @@ describe("hooks/manager", () => { expect(writeFileSync).toHaveBeenCalledOnce(); const [, content] = vi.mocked(writeFileSync).mock.calls[0]; const written = JSON.parse(content as string); - expect(Object.keys(written.hooks)).toHaveLength(29); + expect(Object.keys(written.hooks)).toHaveLength(28); }); it("uses 'where' on Windows and handles multi-line output", async () => { diff --git a/__tests__/hooks/new-telemetry.test.ts b/__tests__/hooks/new-telemetry.test.ts index 349ada0b..c0f58ab8 100644 --- a/__tests__/hooks/new-telemetry.test.ts +++ b/__tests__/hooks/new-telemetry.test.ts @@ -50,6 +50,14 @@ vi.mock("../../src/hooks/integrations", () => ({ hooksInstalledInSettings: vi.fn(() => false), }, listIntegrations: vi.fn(() => []), + // Mirrors the real helper: one path per integration unless it opts into + // multiple (Hermes, one config.yaml per profile). + settingsPathsFor: vi.fn( + (integration: { getSettingsPaths?: (s: string, c?: string) => string[]; getSettingsPath: (s: string, c?: string) => string }, scope: string, cwd?: string) => { + const paths = integration.getSettingsPaths?.(scope, cwd); + return paths && paths.length > 0 ? paths : [integration.getSettingsPath(scope, cwd)]; + }, + ), })); vi.mock("../../src/hooks/hooks-config", () => ({ diff --git a/__tests__/hooks/pi-shim-shapes.test.ts b/__tests__/hooks/pi-shim-shapes.test.ts new file mode 100644 index 00000000..f225010d --- /dev/null +++ b/__tests__/hooks/pi-shim-shapes.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +/** + * Pi's two PreToolUse-mapped events need DIFFERENT return shapes, and both + * handlers build the same `hook_event_name: "PreToolUse"` payload — so a + * search-and-replace across this file can put each return in the other's + * handler without changing a single line of behaviour that typechecks. + * + * That happened. The result was strictly worse than the bug being fixed: the + * agent's `tool_call` gate (bash/read/write/edit — every tool the model calls) + * went inert, while `user_bash` stayed inert. Both silently, because + * `ToolCallEventResult` and `UserBashEventResult` are structurally disjoint and + * each handler simply returns an object the consumer does not branch on. + * + * Vendor types (installed @earendil-works/pi-coding-agent): + * ToolCallEventResult {block?, reason?} extensions/types.d.ts:766 + * UserBashEventResult {operations?, result?} extensions/types.d.ts:772 + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const SHIM = readFileSync(path.join(__dirname, "../../pi-extension/index.ts"), "utf8"); + +/** Source of one `pi.on("", …)` handler, up to the next registration. */ +function handler(event: string): string { + const start = SHIM.indexOf(`pi.on("${event}"`); + expect(start, `no pi.on("${event}") handler`).toBeGreaterThan(-1); + const after = SHIM.indexOf("pi.on(", start + 1); + return SHIM.slice(start, after === -1 ? undefined : after); +} + +describe("pi-extension deny shapes are in the right handlers", () => { + it("tool_call returns ToolCallEventResult {block, reason}", () => { + const h = handler("tool_call"); + expect(h).toMatch(/return \{ block: true, reason: decision\.reason \}/); + // The full-replacement bash shape here would leave every agent tool call + // unguarded — agent-loop only branches on `beforeResult?.block`. + expect(h).not.toMatch(/result:\s*\{/); + }); + + it("user_bash returns UserBashEventResult {result: BashResult}", () => { + const h = handler("user_bash"); + expect(h).toMatch(/result:\s*\{/); + expect(h).toMatch(/exitCode:/); + // `block` does not exist on UserBashEventResult; returning it runs the command. + expect(h).not.toMatch(/return \{ block: true/); + }); + + it("input returns InputEventResult, never a block field", () => { + const h = handler("input"); + expect(h).toMatch(/action: "handled"/); + expect(h).not.toMatch(/block: true/); + }); +}); diff --git a/__tests__/integration-suite/verdict-ordering.test.ts b/__tests__/integration-suite/verdict-ordering.test.ts new file mode 100644 index 00000000..dffaf294 --- /dev/null +++ b/__tests__/integration-suite/verdict-ordering.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +/** + * Tripwire for probe-cli.sh's PASS/FAIL ordering. + * + * The suite gathers two pieces of evidence per probe: our own hooks.log (did + * failproofai emit a deny?) and a side effect on disk (did the CLI run the + * command anyway?). They answer different questions — the log records what WE + * did, the side effect records what the CLI did. + * + * The verdict originally read the log first: + * + * if denied ... hooks.log -> PASS + * elif [ -f CANARY_PROBE_ran ] -> FAIL + * + * so a CLI that logged our deny and executed the command regardless matched the + * first branch and scored PASS — the silent-allow this entire suite exists to + * detect (copilot 1.0.70 shipped exactly that). The proof of failure sat + * unread on disk. + * + * These assert the ground-truth check comes first. A shell script has no unit + * seam, so this reads the source — the same approach is_error.test.ts and + * dogfood-configs.test.ts take. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const SUITE = path.join(__dirname, "../../integration-suite"); +const probeSh = readFileSync(path.join(SUITE, "probe-cli.sh"), "utf8"); + +/** Byte offset of the first line matching `re`, or -1. */ +function lineAt(re: RegExp): number { + const lines = probeSh.split("\n"); + return lines.findIndex((l) => re.test(l)); +} + +describe("probe-cli.sh verdict ordering", () => { + it("probe A decides FAIL on the marker file before consulting our own log", () => { + const failLine = lineAt(/^if \[ -f "\$BASE\/CANARY_PROBE_ran" \]; then VA=FAIL/); + const passLine = lineAt(/^elif denied canary-bash "\$LOGA\/hooks\.log"; then VA=PASS/); + + expect(failLine).toBeGreaterThan(-1); + expect(passLine).toBeGreaterThan(-1); + // The side effect is ground truth; our log is only a claim about intent. + expect(failLine).toBeLessThan(passLine); + }); + + it("probe B decides FAIL on the leaked sentinel before consulting our own log", () => { + const failLine = lineAt(/^if printf '%s' "\$OUTB" \| grep -qF "\$MARKER_CONTENT"; then VB=FAIL/); + const passLine = lineAt(/^elif read_denied "\$LOGB\/hooks\.log"; then VB=PASS/); + + expect(failLine).toBeGreaterThan(-1); + expect(passLine).toBeGreaterThan(-1); + expect(failLine).toBeLessThan(passLine); + }); + + it("never scores PASS from the hook log alone in a leading branch", () => { + // Guards the general shape rather than the two exact lines above: any + // `if ; then V?=PASS` opening a verdict block reintroduces the + // bug, whatever the surrounding text looks like. + const leadingLogPass = /^if\s+(denied|read_denied)[^\n]*;\s*then\s+V[AB]=PASS/m; + expect(probeSh).not.toMatch(leadingLogPass); + }); +}); diff --git a/__tests__/lib/hermes-multi-profile.test.ts b/__tests__/lib/hermes-multi-profile.test.ts new file mode 100644 index 00000000..d607c7e8 --- /dev/null +++ b/__tests__/lib/hermes-multi-profile.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node +// +// Multi-profile Hermes audit path. Builds a REAL profile tree — /state.db +// plus /profiles//state.db — with the bundled sql.js driver, points +// HERMES_HOME at it, and proves the whole chain: enumeration tags each session +// with its profile, projects group by (profile, source), and a session in a +// NON-default profile still resolves through the unchanged `hermes://` form. +// +// Before profile support, every one of these sessions except the default +// profile's was invisible to both the dashboard and `failproofai audit`. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import initSqlJs from "sql.js/dist/sql-asm.js"; + +let root: string; +const prevHome = process.env.HERMES_HOME; +const prevDbPath = process.env.HERMES_DB_PATH; + +const DEFAULT_ID = "20260709_102452_def"; +const WORK_ID = "20260710_090000_work"; +const HYPHEN_ID = "20260711_090000_hyph"; + +async function writeDb( + path: string, + session: { id: string; source: string; title: string; ts: number }, +): Promise { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, cwd TEXT, title TEXT, user_id TEXT, chat_id TEXT, chat_type TEXT, started_at REAL, ended_at REAL, message_count INTEGER);", + ); + db.run( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL);", + ); + db.run("INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?)", [ + session.id, session.source, null, session.title, "U1", "C1", "dm", session.ts, session.ts + 10, 1, + ]); + db.run("INSERT INTO messages VALUES (?,?,?,?,?,?,?,?)", [ + 1, session.id, "user", `hello from ${session.title}`, null, null, null, session.ts + 1, + ]); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, Buffer.from(db.export())); + db.close(); +} + +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "hermes-multi-")); + await writeDb(join(root, "state.db"), { + id: DEFAULT_ID, source: "slack", title: "default session", ts: 1_752_000_000, + }); + await writeDb(join(root, "profiles", "work", "state.db"), { + id: WORK_ID, source: "telegram", title: "work session", ts: 1_752_100_000, + }); + // A profile name containing "-" is the case a naive split on the project name + // would corrupt. + await writeDb(join(root, "profiles", "my-bot", "state.db"), { + id: HYPHEN_ID, source: "cron", title: "my-bot session", ts: 1_752_200_000, + }); + // A profile with no state.db at all must be skipped, not throw. + mkdirSync(join(root, "profiles", "empty"), { recursive: true }); + + delete process.env.HERMES_DB_PATH; + process.env.HERMES_HOME = root; +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prevHome; + if (prevDbPath === undefined) delete process.env.HERMES_DB_PATH; + else process.env.HERMES_DB_PATH = prevDbPath; + rmSync(root, { recursive: true, force: true }); +}); + +describe("hermes multi-profile enumeration", () => { + it("reads every profile's state.db and tags each session with its profile", async () => { + const { getHermesSessions } = await import("@/lib/hermes-projects"); + const sessions = await getHermesSessions(); + expect(sessions.find((s) => s.sessionId === DEFAULT_ID)?.profile).toBe("default"); + expect(sessions.find((s) => s.sessionId === WORK_ID)?.profile).toBe("work"); + expect(sessions.find((s) => s.sessionId === HYPHEN_ID)?.profile).toBe("my-bot"); + }); + + it("sorts merged sessions by last activity across profiles", async () => { + const { getHermesSessions } = await import("@/lib/hermes-projects"); + const ids = (await getHermesSessions()).map((s) => s.sessionId); + expect(ids).toEqual([HYPHEN_ID, WORK_ID, DEFAULT_ID]); // newest first + }); + + it("groups projects by (profile, source)", async () => { + const { getHermesProjects } = await import("@/lib/hermes-projects"); + const projects = await getHermesProjects(); + expect(projects.map((p) => p.name).sort()).toEqual([ + "hermes-default-slack", + "hermes-my-bot-cron", + "hermes-work-telegram", + ]); + expect(projects.find((p) => p.name === "hermes-work-telegram")?.path).toBe( + "hermes:work:telegram", + ); + }); + + it("resolves a project name whose profile contains a hyphen", async () => { + const { getHermesSessionsByEncodedName } = await import("@/lib/hermes-projects"); + const { cwd, sessions } = await getHermesSessionsByEncodedName("hermes-my-bot-cron"); + expect(cwd).toBe("hermes:my-bot:cron"); + expect(sessions.map((s) => s.sessionId)).toEqual([HYPHEN_ID]); + }); + + it("does not leak one profile's sessions into another's project", async () => { + const { getHermesSessionsByEncodedName } = await import("@/lib/hermes-projects"); + const { sessions } = await getHermesSessionsByEncodedName("hermes-default-slack"); + expect(sessions.map((s) => s.sessionId)).toEqual([DEFAULT_ID]); + }); + + it("still resolves the legacy `hermes-` name against the default profile", async () => { + // Links made before profile support must keep working. + const { getHermesSessionsByEncodedName } = await import("@/lib/hermes-projects"); + const { sessions } = await getHermesSessionsByEncodedName("hermes-slack"); + expect(sessions.map((s) => s.sessionId)).toEqual([DEFAULT_ID]); + }); + + it("loads a NON-default profile's session through the unchanged hermes:// form", async () => { + // The identifier deliberately carries no profile: getHermesSessionLog + // searches every profile DB, so transcript paths already stored in audit + // history keep resolving. + const { getHermesSessionLog } = await import("@/lib/hermes-sessions"); + const log = await getHermesSessionLog(WORK_ID); + expect(log).not.toBeNull(); + expect(log!.profile).toBe("work"); + expect(log!.filePath).toBe(`hermes://${WORK_ID}`); + expect(log!.cwd).toBe("hermes:work:telegram"); // cwd fallback matches the grouping key + expect(log!.entries[0]).toMatchObject({ type: "user" }); + }); + + it("adapter groups transcripts by profile and channel", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const metas = await listHermesTranscriptMetadata(); + expect(metas.find((m) => m.sessionId === WORK_ID)).toMatchObject({ + cli: "hermes", + projectName: "hermes:work:telegram", + transcriptPath: `hermes://${WORK_ID}`, + }); + expect(metas.find((m) => m.sessionId === DEFAULT_ID)?.projectName).toBe("hermes:default:slack"); + }); +}); diff --git a/__tests__/lib/hermes-profiles.test.ts b/__tests__/lib/hermes-profiles.test.ts new file mode 100644 index 00000000..43ed6499 --- /dev/null +++ b/__tests__/lib/hermes-profiles.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +// +// Hermes profile discovery. A profile is a whole separate Hermes home dir with +// its own config.yaml + state.db, so getting this wrong means either invisible +// sessions (audit) or unhooked gateways (enforcement) — see lib/hermes-profiles.ts. +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const prevHome = process.env.HERMES_HOME; +const prevDbPath = process.env.HERMES_DB_PATH; +const dirs: string[] = []; + +function makeRoot(profiles: string[] = []): string { + const root = mkdtempSync(join(tmpdir(), "hermes-root-")); + dirs.push(root); + writeFileSync(join(root, "config.yaml"), "hooks: {}\n"); + for (const name of profiles) { + mkdirSync(join(root, "profiles", name), { recursive: true }); + } + return root; +} + +beforeEach(() => { + delete process.env.HERMES_DB_PATH; +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prevHome; + if (prevDbPath === undefined) delete process.env.HERMES_DB_PATH; + else process.env.HERMES_DB_PATH = prevDbPath; + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +describe("listHermesProfiles", () => { + it("returns just the default profile when there is no profiles/ dir", async () => { + const root = makeRoot(); + process.env.HERMES_HOME = root; + const { listHermesProfiles } = await import("@/lib/hermes-profiles"); + expect(listHermesProfiles()).toEqual([{ name: "default", home: root }]); + }); + + it("lists the root as `default` first, then each profile dir in name order", async () => { + const root = makeRoot(["work", "coder"]); + process.env.HERMES_HOME = root; + const { listHermesProfiles } = await import("@/lib/hermes-profiles"); + expect(listHermesProfiles().map((p) => p.name)).toEqual(["default", "coder", "work"]); + expect(listHermesProfiles()[2].home).toBe(join(root, "profiles", "work")); + }); + + it("keeps profile names containing a hyphen intact", async () => { + const root = makeRoot(["my-bot"]); + process.env.HERMES_HOME = root; + const { listHermesProfiles } = await import("@/lib/hermes-profiles"); + expect(listHermesProfiles().map((p) => p.name)).toContain("my-bot"); + }); + + it("climbs to the root when HERMES_HOME points AT a profile", async () => { + // The per-profile alias wrapper exports HERMES_HOME=/profiles/, + // so discovery must still see every sibling profile. + const root = makeRoot(["work", "coder"]); + process.env.HERMES_HOME = join(root, "profiles", "work"); + const { listHermesProfiles, hermesRoot } = await import("@/lib/hermes-profiles"); + expect(hermesRoot()).toBe(root); + expect(listHermesProfiles().map((p) => p.name)).toEqual(["default", "coder", "work"]); + }); + + it("skips dotfiles and follows symlinked profile dirs", async () => { + const root = makeRoot(["real"]); + mkdirSync(join(root, "profiles", ".hidden"), { recursive: true }); + const linkTarget = mkdtempSync(join(tmpdir(), "hermes-linked-")); + dirs.push(linkTarget); + symlinkSync(linkTarget, join(root, "profiles", "linked")); + process.env.HERMES_HOME = root; + const { listHermesProfiles } = await import("@/lib/hermes-profiles"); + const names = listHermesProfiles().map((p) => p.name); + expect(names).toContain("real"); + expect(names).toContain("linked"); + expect(names).not.toContain(".hidden"); + }); + + it("does not emit a duplicate when a profile dir is literally named `default`", async () => { + const root = makeRoot(["default"]); + process.env.HERMES_HOME = root; + const { listHermesProfiles } = await import("@/lib/hermes-profiles"); + const names = listHermesProfiles().map((p) => p.name); + expect(names.filter((n) => n === "default")).toHaveLength(1); + expect(listHermesProfiles()[0].home).toBe(root); // the root wins + }); +}); + +describe("hermesDbPaths", () => { + it("resolves one state.db per profile", async () => { + const root = makeRoot(["work"]); + process.env.HERMES_HOME = root; + const { hermesDbPaths } = await import("@/lib/hermes-sessions"); + expect(hermesDbPaths()).toEqual([ + { profile: "default", dbPath: join(root, "state.db") }, + { profile: "work", dbPath: join(root, "profiles", "work", "state.db") }, + ]); + }); + + it("collapses to a single default profile when HERMES_DB_PATH is set", async () => { + // Back-compat: pointing at a copied/remote state.db must keep working. + const root = makeRoot(["work"]); + process.env.HERMES_HOME = root; + process.env.HERMES_DB_PATH = "/tmp/copied-state.db"; + const { hermesDbPaths } = await import("@/lib/hermes-sessions"); + expect(hermesDbPaths()).toEqual([{ profile: "default", dbPath: "/tmp/copied-state.db" }]); + }); +}); diff --git a/__tests__/lib/hermes-sqlite.test.ts b/__tests__/lib/hermes-sqlite.test.ts index ecf9a377..561b7d5e 100644 --- a/__tests__/lib/hermes-sqlite.test.ts +++ b/__tests__/lib/hermes-sqlite.test.ts @@ -107,7 +107,7 @@ describe("hermes SQLite integration (real sql.js + temp state.db)", () => { const { getHermesSessionLog } = await import("@/lib/hermes-sessions"); const log = await getHermesSessionLog(PR_ID); expect(log).not.toBeNull(); - expect(log!.cwd).toBe("hermes:slack"); // no cwd → grouped by source + expect(log!.cwd).toBe("hermes:default:slack"); // no cwd → grouped by (profile, source) const assistant = log!.entries.find((e) => e.type === "assistant"); const toolUse = assistant?.type === "assistant" @@ -117,12 +117,12 @@ describe("hermes SQLite integration (real sql.js + temp state.db)", () => { expect(toolUse && "result" in toolUse ? toolUse.result?.content : "").toContain("PR #287"); }); - it("adapter lists non-empty sessions with message_count as cache key + source grouping", async () => { + it("adapter lists non-empty sessions with message_count as cache key + (profile, source) grouping", async () => { const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); const metas = await listHermesTranscriptMetadata(); expect(metas.find((m) => m.sessionId === PR_ID)).toMatchObject({ cli: "hermes", - projectName: "hermes:slack", + projectName: "hermes:default:slack", sizeBytes: 3, }); expect(metas.some((m) => m.sessionId === EMPTY_ID)).toBe(false); // empty filtered out @@ -139,7 +139,11 @@ describe("hermes SQLite integration (real sql.js + temp state.db)", () => { sizeBytes: 3, }); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ toolName: "Bash", rawToolName: "terminal", cwd: "hermes:slack" }); + expect(events[0]).toMatchObject({ + toolName: "Bash", + rawToolName: "terminal", + cwd: "hermes:default:slack", + }); }); it("returns null for a session that doesn't exist", async () => { diff --git a/__tests__/lib/openclaw-projects.test.ts b/__tests__/lib/openclaw-projects.test.ts index 52cf424b..0dd7af07 100644 --- a/__tests__/lib/openclaw-projects.test.ts +++ b/__tests__/lib/openclaw-projects.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node // // Covers OpenClaw dashboard enumeration: reads the on-disk transcripts + -// per-agent sessions.json index, groups by **channel** (from metadata fields — -// OpenClaw routes gateway sessions through the default key and records the -// channel in `lastChannel`/`origin`, verified live v2026.7.1), and names -// sessions from the human-readable `origin.label`. OPENCLAW_HOME fixture. +// per-agent sessions.json index, groups by **(agent, channel)** (channel comes +// from metadata fields — OpenClaw routes gateway sessions through the default +// key and records the channel in `lastChannel`/`origin`, verified live +// v2026.7.1), and names sessions from the human-readable `origin.label`. +// OPENCLAW_HOME fixture. import { describe, it, expect, afterEach } from "vitest"; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -13,20 +14,29 @@ import { getOpenClawSessions, getOpenClawProjects, getOpenClawSessionsByEncodedName, + parseOpenClawProjectName, } from "@/lib/openclaw-projects"; const UUID_TG = "aa111111-2222-3333-4444-555555555555"; const UUID_CLI = "f9e8516e-fed2-4e54-acbe-7a20aefc6cfa"; +// A SECOND agent, also on telegram — the case that collapsed into one mixed +// project row when grouping was channel-only. Its id contains a hyphen, which +// is why the name parser can't just split the slug. +const UUID_WEATHER = "bb222222-3333-4444-5555-666666666666"; let home: string | undefined; const prev = process.env.OPENCLAW_HOME; +function writeSession(dir: string, uuid: string): void { + writeFileSync(join(dir, `${uuid}.jsonl`), JSON.stringify({ type: "session", cwd: "/x" }) + "\n"); +} + function seed(): string { const h = mkdtempSync(join(tmpdir(), "openclaw-proj-")); const sessions = join(h, "agents", "main", "sessions"); mkdirSync(sessions, { recursive: true }); - writeFileSync(join(sessions, `${UUID_TG}.jsonl`), JSON.stringify({ type: "session", cwd: "/x" }) + "\n"); - writeFileSync(join(sessions, `${UUID_CLI}.jsonl`), JSON.stringify({ type: "session", cwd: "/x" }) + "\n"); + writeSession(sessions, UUID_TG); + writeSession(sessions, UUID_CLI); writeFileSync( join(sessions, "sessions.json"), JSON.stringify({ @@ -43,6 +53,22 @@ function seed(): string { "agent:main:cli": { sessionId: UUID_CLI, lastInteractionAt: 2000 }, }), ); + + const weather = join(h, "agents", "weather-bot", "sessions"); + mkdirSync(weather, { recursive: true }); + writeSession(weather, UUID_WEATHER); + writeFileSync( + join(weather, "sessions.json"), + JSON.stringify({ + "agent:weather-bot:main": { + sessionId: UUID_WEATHER, + lastInteractionAt: 9000, + lastChannel: "telegram", + origin: { label: "Weather Bot", provider: "telegram" }, + }, + }), + ); + process.env.OPENCLAW_HOME = h; return h; } @@ -58,8 +84,9 @@ describe("getOpenClawSessions", () => { it("derives channel + label + chat metadata from sessions.json (not the key)", async () => { home = seed(); const sessions = await getOpenClawSessions(); - // Sorted by mtime desc → telegram (5000) before cli (2000). - expect(sessions.map((s) => s.sessionId)).toEqual([UUID_TG, UUID_CLI]); + // Sorted by mtime desc, ACROSS agents → weather-bot (9000), telegram + // (5000), cli (2000). + expect(sessions.map((s) => s.sessionId)).toEqual([UUID_WEATHER, UUID_TG, UUID_CLI]); const tg = sessions.find((s) => s.sessionId === UUID_TG)!; expect(tg.channel).toBe("telegram"); @@ -74,18 +101,90 @@ describe("getOpenClawSessions", () => { }); describe("getOpenClawProjects / getOpenClawSessionsByEncodedName", () => { - it("groups sessions into one project per channel", async () => { + it("groups sessions into one project per (agent, channel), newest first", async () => { home = seed(); const projects = await getOpenClawProjects(); - // telegram (newest) first, then local. - expect(projects.map((p) => p.name)).toEqual(["openclaw-telegram", "openclaw-local"]); - expect(projects[0].path).toBe("openclaw:telegram"); + // weather-bot/telegram (9000), main/telegram (5000), main/local (2000). + expect(projects.map((p) => p.name)).toEqual([ + "openclaw-weather-bot-telegram", + "openclaw-main-telegram", + "openclaw-main-local", + ]); + expect(projects[0].path).toBe("openclaw:weather-bot:telegram"); expect(projects[0].cli).toEqual(["openclaw"]); }); + it("keeps two agents on the SAME channel in separate projects", async () => { + // Regression: grouping by channel alone collapsed every agent on Telegram + // into one row with one mixed session list. + home = seed(); + const mainTg = await getOpenClawSessionsByEncodedName("openclaw-main-telegram"); + const weatherTg = await getOpenClawSessionsByEncodedName("openclaw-weather-bot-telegram"); + expect(mainTg.sessions.map((s) => s.sessionId)).toEqual([UUID_TG]); + expect(weatherTg.sessions.map((s) => s.sessionId)).toEqual([UUID_WEATHER]); + }); + + it("reports a session count per project", async () => { + home = seed(); + const projects = await getOpenClawProjects(); + expect(projects.map((p) => p.sessionCount)).toEqual([1, 1, 1]); + }); + + it("parses an agent id containing a hyphen without splitting the slug", async () => { + home = seed(); + expect(parseOpenClawProjectName("openclaw-weather-bot-telegram")).toEqual({ + agentId: "weather-bot", + channel: "telegram", + }); + expect(parseOpenClawProjectName("openclaw-main-local")).toEqual({ + agentId: "main", + channel: "local", + }); + expect(parseOpenClawProjectName("claude-foo")).toBeNull(); + }); + + it("resolves the right agent when one agent id is a hyphen-prefix of another", async () => { + // Regression: `openclaw-main-bot-telegram` can split as main-bot+telegram OR + // main+bot-telegram. Longest-match alone always picks the former, which is + // wrong when the shorter agent owns the session — so candidates are + // validated against real sessions. + home = mkdtempSync(join(tmpdir(), "openclaw-ambig-")); + const mainSessions = join(home, "agents", "main", "sessions"); + const botSessions = join(home, "agents", "main-bot", "sessions"); + mkdirSync(mainSessions, { recursive: true }); + mkdirSync(botSessions, { recursive: true }); + writeSession(mainSessions, UUID_TG); + writeFileSync( + join(mainSessions, "sessions.json"), + JSON.stringify({ + "agent:main:main": { sessionId: UUID_TG, lastInteractionAt: 5000, lastChannel: "bot-telegram" }, + }), + ); + process.env.OPENCLAW_HOME = home; + + // `main-bot` exists on disk, so it wins the longest-match — but owns nothing. + expect(parseOpenClawProjectName("openclaw-main-bot-telegram")).toEqual({ + agentId: "main-bot", + channel: "telegram", + }); + // Resolution still finds the real owner. + const resolved = await getOpenClawSessionsByEncodedName("openclaw-main-bot-telegram"); + expect(resolved.sessions.map((s) => s.sessionId)).toEqual([UUID_TG]); + expect(resolved.cwd).toBe("openclaw:main:bot-telegram"); + }); + + it("still resolves the legacy channel-only name across every agent", async () => { + // Links shared before the agent segment existed meant "this channel, all + // agents" — keep them meaning exactly that instead of 404ing. + home = seed(); + const legacy = await getOpenClawSessionsByEncodedName("openclaw-telegram"); + expect(legacy.cwd).toBe("openclaw:telegram"); + expect(legacy.sessions.map((s) => s.sessionId).sort()).toEqual([UUID_TG, UUID_WEATHER].sort()); + }); + it("names sessions by origin.label and carries channel metadata; non-openclaw names return empty", async () => { home = seed(); - const tg = await getOpenClawSessionsByEncodedName("openclaw-telegram"); + const tg = await getOpenClawSessionsByEncodedName("openclaw-main-telegram"); expect(tg.sessions).toHaveLength(1); const s = tg.sessions[0]; expect(s.name).toBe("Chetan (@chhhee10) id:8674922496"); // readable, not the raw key @@ -94,7 +193,7 @@ describe("getOpenClawProjects / getOpenClawSessionsByEncodedName", () => { expect(s.channelId).toBe("telegram:8674922496"); expect(s.channelType).toBe("direct"); - const local = await getOpenClawSessionsByEncodedName("openclaw-local"); + const local = await getOpenClawSessionsByEncodedName("openclaw-main-local"); expect(local.sessions).toHaveLength(1); expect(local.sessions[0].name).toBe(UUID_CLI); // no label → falls back to id diff --git a/__tests__/lib/paths.test.ts b/__tests__/lib/paths.test.ts index 5e984f70..641383c9 100644 --- a/__tests__/lib/paths.test.ts +++ b/__tests__/lib/paths.test.ts @@ -6,7 +6,38 @@ vi.mock("os", () => ({ homedir: vi.fn(() => "/mock/home"), })); -import { decodeFolderName, getDefaultClaudeProjectsPath, getClaudeProjectsPath } from "@/lib/paths"; +import { + decodeFolderName, + projectDisplayName, + getDefaultClaudeProjectsPath, + getClaudeProjectsPath, +} from "@/lib/paths"; + +describe("projectDisplayName", () => { + it("decodes filesystem-backed project folder names as before", () => { + expect(projectDisplayName("-home-user-project", "/home/user/project")).toBe( + "/home/user/project", + ); + expect(projectDisplayName("-home-user-project")).toBe("/home/user/project"); + }); + + it("renders gateway projects from their path, keeping hyphens in the segments", () => { + // decodeFolderName would split the Hermes profile `my-bot` into `my/bot`. + expect(projectDisplayName("hermes-my-bot-cron", "hermes:my-bot:cron")).toBe( + "hermes/my-bot/cron", + ); + expect(projectDisplayName("hermes-default-slack", "hermes:default:slack")).toBe( + "hermes/default/slack", + ); + expect(projectDisplayName("openclaw-telegram", "openclaw:telegram")).toBe( + "openclaw/telegram", + ); + }); + + it("does not mistake a lowercase Windows drive letter for a gateway path", () => { + expect(projectDisplayName("c--code-project", "c:/code/project")).toBe("c:/code/project"); + }); +}); describe("decodeFolderName", () => { it("decodes Windows drive-letter path: C--code-project", () => { diff --git a/__tests__/lib/project-tree.test.ts b/__tests__/lib/project-tree.test.ts new file mode 100644 index 00000000..fd216fca --- /dev/null +++ b/__tests__/lib/project-tree.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment node +// +// The projects panel renders gateway CLIs (Hermes, OpenClaw) as a folder tree, +// derived purely from the synthetic `path` their producers emit. These tests +// pin the derivation: real filesystem projects must stay flat leaves, gateway +// paths must nest, and the rollups a collapsed folder row shows (session count, +// last activity, CLI badges) must reflect everything underneath it. +import { describe, it, expect } from "vitest"; +import { + buildProjectTree, + visibleTreeRows, + collapsibleKeys, + type ProjectTreeNode, +} from "@/lib/project-tree"; +import type { ProjectFolder } from "@/lib/projects"; + +function folder( + name: string, + path: string, + ms: number, + extra: Partial = {}, +): ProjectFolder { + return { + name, + path, + isDirectory: true, + lastModified: new Date(ms), + cli: ["hermes"], + ...extra, + }; +} + +/** Node lookup by key, depth-first. */ +function find(nodes: ProjectTreeNode[], key: string): ProjectTreeNode | undefined { + for (const n of nodes) { + if (n.key === key) return n; + const hit = find(n.children, key); + if (hit) return hit; + } + return undefined; +} + +describe("buildProjectTree", () => { + it("keeps real filesystem projects as flat top-level leaves", () => { + const tree = buildProjectTree([ + folder("-home-user-app", "/home/user/app", 2000, { cli: ["claude"] }), + folder("-home-user-lib", "/home/user/lib", 1000, { cli: ["codex"] }), + ]); + expect(tree).toHaveLength(2); + expect(tree.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true); + expect(tree[0].project?.name).toBe("-home-user-app"); // newest first + expect(collapsibleKeys(tree)).toEqual([]); // nothing to expand + }); + + it("nests a gateway path one node per segment, leaf carrying the project", () => { + const tree = buildProjectTree([folder("hermes-work-slack", "hermes:work:slack", 5000)]); + expect(tree).toHaveLength(1); + const root = tree[0]; + expect(root).toMatchObject({ key: "hermes", label: "hermes", depth: 0 }); + expect(root.project).toBeUndefined(); // pure grouping row — must not link + + const profile = root.children[0]; + expect(profile).toMatchObject({ key: "hermes:work", label: "work", depth: 1 }); + + const leaf = profile.children[0]; + expect(leaf).toMatchObject({ key: "hermes:work:slack", label: "slack", depth: 2 }); + expect(leaf.project?.name).toBe("hermes-work-slack"); // the /project/ link + expect(leaf.children).toEqual([]); + }); + + it("groups profiles under one CLI root and channels under one profile", () => { + const tree = buildProjectTree([ + folder("hermes-default-slack", "hermes:default:slack", 1000), + folder("hermes-default-cron", "hermes:default:cron", 3000), + folder("hermes-work-telegram", "hermes:work:telegram", 2000), + ]); + expect(tree).toHaveLength(1); + expect(tree[0].children.map((c) => c.label)).toEqual(["default", "work"]); // by recency + expect(find(tree, "hermes:default")!.children.map((c) => c.label)).toEqual(["cron", "slack"]); + }); + + it("keeps a profile name containing a hyphen as ONE segment", () => { + // The whole reason grouping is derived from `path` (colon-separated) rather + // than `name`: decodeFolderName would split `my-bot` into `my/bot`. + const tree = buildProjectTree([ + folder("hermes-my-bot-cron", "hermes:my-bot:cron", 1000), + ]); + expect(find(tree, "hermes:my-bot")).toBeDefined(); + expect(find(tree, "hermes:my-bot")!.label).toBe("my-bot"); + }); + + it("nests OpenClaw's three levels: cli -> agent -> channel", () => { + const tree = buildProjectTree([ + folder("openclaw-weather-bot-telegram", "openclaw:weather-bot:telegram", 4000, { + cli: ["openclaw"], + }), + ]); + const leaf = find(tree, "openclaw:weather-bot:telegram")!; + expect(leaf.depth).toBe(2); + expect(find(tree, "openclaw:weather-bot")!.label).toBe("weather-bot"); + }); + + it("mixes gateway trees and flat projects in one recency-sorted list", () => { + const tree = buildProjectTree([ + folder("hermes-work-slack", "hermes:work:slack", 1000), + folder("-home-user-app", "/home/user/app", 9000, { cli: ["claude"] }), + ]); + expect(tree.map((n) => n.label)).toEqual(["-home-user-app", "hermes"]); // newest root first + }); + + describe("rollups on folder rows", () => { + const tree = buildProjectTree([ + folder("hermes-default-slack", "hermes:default:slack", 1000, { sessionCount: 3 }), + folder("hermes-default-cron", "hermes:default:cron", 8000, { sessionCount: 4 }), + folder("hermes-work-telegram", "hermes:work:telegram", 2000, { sessionCount: 5 }), + ]); + + it("sums session counts up every level", () => { + expect(find(tree, "hermes:default")!.sessionCount).toBe(7); + expect(find(tree, "hermes")!.sessionCount).toBe(12); + }); + + it("reports the newest activity anywhere beneath a folder", () => { + expect(find(tree, "hermes:default")!.lastModified.getTime()).toBe(8000); + expect(find(tree, "hermes")!.lastModified.getTime()).toBe(8000); + }); + + it("leaves sessionCount undefined when nothing underneath reported one", () => { + // undefined must NOT collapse to 0 — "unknown" and "empty" read the same + // to a user but mean opposite things. + const noCounts = buildProjectTree([folder("hermes-a-slack", "hermes:a:slack", 1)]); + expect(find(noCounts, "hermes")!.sessionCount).toBeUndefined(); + }); + + it("counts the projects each row covers, for pagination", () => { + expect(find(tree, "hermes")!.projectCount).toBe(3); + expect(find(tree, "hermes:default")!.projectCount).toBe(2); + expect(find(tree, "hermes:work:telegram")!.projectCount).toBe(1); + }); + + it("unions CLI badges up the tree", () => { + const mixed = buildProjectTree([ + folder("hermes-a-slack", "hermes:a:slack", 1, { cli: ["hermes"] }), + folder("hermes-b-cron", "hermes:b:cron", 2, { cli: ["hermes", "claude"] }), + ]); + expect(find(mixed, "hermes")!.cli).toEqual(["hermes", "claude"]); + }); + }); +}); + +describe("visibleTreeRows", () => { + const tree = buildProjectTree([ + folder("hermes-default-slack", "hermes:default:slack", 1000), + folder("hermes-work-telegram", "hermes:work:telegram", 2000), + ]); + + it("returns every row when nothing is collapsed", () => { + const rows = visibleTreeRows(tree, () => false); + expect(rows.map((r) => r.key)).toEqual([ + "hermes", + "hermes:work", + "hermes:work:telegram", + "hermes:default", + "hermes:default:slack", + ]); + }); + + it("hides an entire subtree when its root is collapsed", () => { + const rows = visibleTreeRows(tree, (k) => k === "hermes"); + expect(rows.map((r) => r.key)).toEqual(["hermes"]); + }); + + it("hides only the collapsed branch, not its siblings", () => { + const rows = visibleTreeRows(tree, (k) => k === "hermes:work"); + expect(rows.map((r) => r.key)).toEqual([ + "hermes", + "hermes:work", + "hermes:default", + "hermes:default:slack", + ]); + }); + + it("lists every collapsible row and no leaves", () => { + expect(collapsibleKeys(tree)).toEqual(["hermes", "hermes:work", "hermes:default"]); + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index 94121532..427e6bd6 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -53,6 +53,15 @@ vi.mock("@/lib/antigravity-projects", () => ({ getAntigravityProjects: vi.fn(async () => []), })); +// Same reason as Hermes/Antigravity above, and it was genuinely missing: the +// `vi.mock("fs/promises")` at the top does NOT cover OpenClaw, which reads +// ~/.openclaw with SYNC node:fs calls. This file only passed on machines with +// no OpenClaw agents on disk — anyone with real sessions got extra merged rows +// and every toHaveLength() assertion below failed. +vi.mock("@/lib/openclaw-projects", () => ({ + getOpenClawProjects: vi.fn(async () => []), +})); + import { readdir, stat } from "fs/promises"; import { extractSessionId, getProjectFolders, getSessionFiles, type ProjectFolder } from "@/lib/projects"; import { getCodexProjects } from "@/lib/codex-projects"; @@ -555,6 +564,65 @@ describe("getProjectFolders", () => { expect(result[0].path).toBe("hermes:slack"); }); + it("sums sessionCount when two sources share a name, instead of keeping only the first", async () => { + // Regression guard: the merged row is built by spreading the FIRST source, + // so a new field is silently inherited from it and every later source's + // value dropped — with no type error and no other failing test. + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([] as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "shared-row", + path: "/shared", + isDirectory: true, + lastModified: new Date("2026-01-01T00:00:00Z"), + cli: ["codex"], + sessionCount: 2, + } satisfies ProjectFolder, + ]); + mockGetHermesProjects.mockResolvedValueOnce([ + { + name: "shared-row", + path: "/shared", + isDirectory: true, + lastModified: new Date("2026-02-01T00:00:00Z"), + cli: ["hermes"], + sessionCount: 5, + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].sessionCount).toBe(7); + }); + + it("leaves sessionCount undefined when no source reported one", async () => { + // `0` would claim "no sessions" for producers that simply do not count. + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([] as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "uncounted", + path: "/uncounted", + isDirectory: true, + lastModified: new Date("2026-01-01T00:00:00Z"), + cli: ["codex"], + } satisfies ProjectFolder, + ]); + mockGetHermesProjects.mockResolvedValueOnce([ + { + name: "uncounted", + path: "/uncounted", + isDirectory: true, + lastModified: new Date("2026-02-01T00:00:00Z"), + cli: ["hermes"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result[0].sessionCount).toBeUndefined(); + }); + it("falls back gracefully when getCursorProjects rejects", async () => { mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); mockReaddir.mockResolvedValueOnce([ diff --git a/app/actions/get-hooks-config.ts b/app/actions/get-hooks-config.ts index f3ebfc77..7bd9aba6 100644 --- a/app/actions/get-hooks-config.ts +++ b/app/actions/get-hooks-config.ts @@ -7,8 +7,12 @@ import { listIntegrations } from "@/src/hooks/integrations"; import { HOOK_SCOPES } from "@/src/hooks/types"; import type { HookScope, IntegrationType } from "@/src/hooks/types"; import { getCliLabel } from "@/lib/cli-registry"; +import { discoverPolicyFiles } from "@/src/hooks/custom-hooks-loader"; +import { findProjectConfigDir } from "@/src/hooks/hooks-config"; import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, resolve } from "node:path"; export interface PolicyParamSpec { type: string; @@ -34,6 +38,25 @@ export interface CustomPolicyInfo { eventScope?: string; } +/** + * One convention-discovered policy file (`.failproofai/policies/*policies.mjs`) + * and the policies it declares. + * + * These are registered by the filesystem, never by `policies-config.json` — the + * whole point of the convention is that dropping a file in works with no config + * at all. So the dashboard has to discover them the same way the hook path and + * `failproofai policies --list` do; reading config alone renders nothing and + * makes a working policy look absent. + */ +export interface ConventionPolicyFile { + scope: "project" | "user"; + /** Basename, e.g. `security-policies.mjs`. */ + file: string; + /** Absolute path, shown so the user knows which file to edit. */ + path: string; + policies: CustomPolicyInfo[]; +} + export interface CliInstallStatus { id: IntegrationType; label: string; @@ -54,6 +77,65 @@ export interface HooksConfigPayload { policies: PolicyInfo[]; customPoliciesPath?: string; customPolicies?: CustomPolicyInfo[]; + /** Convention-discovered policy files, project scope first. */ + conventionPolicies: ConventionPolicyFile[]; +} + +/** + * Discover `.failproofai/policies/*policies.{js,mjs,ts}` at project and user + * scope and statically parse each one. + * + * Deliberately parsed, never imported: `manager.ts` executes these files to + * list them, which is fine for a one-shot CLI but would run arbitrary user code + * inside the long-lived dashboard server on every page load. + * `parseCustomPoliciesFromFile` is a regex read, so a malformed policy file + * degrades to "no policies listed" instead of taking the server down. + */ +async function discoverConventionPolicies(): Promise { + // Two corrections over a bare `process.cwd()`, both of which made real + // project policies invisible here while enforcement loaded them fine: + // - the standalone server chdir()s into the package on startup, so the real + // launch directory arrives via FAILPROOFAI_LAUNCH_CWD (scripts/launch.ts); + // - enforcement resolves the project root by walking UP to the nearest + // `.failproofai` marker (custom-hooks-loader -> findProjectConfigDir), so + // resolving at the exact cwd disagrees with it from any subdirectory. + const launchCwd = process.env.FAILPROOFAI_LAUNCH_CWD || process.cwd(); + const projectDir = resolve(findProjectConfigDir(launchCwd), ".failproofai", "policies"); + const userDir = resolve(homedir(), ".failproofai", "policies"); + + const dirs: { scope: "project" | "user"; dir: string }[] = [ + { scope: "project", dir: projectDir }, + // Running the dashboard from $HOME makes both paths identical; listing the + // same file twice would read as two separate installs. + ...(userDir === projectDir ? [] : [{ scope: "user" as const, dir: userDir }]), + ]; + + const out: ConventionPolicyFile[] = []; + for (const { scope, dir } of dirs) { + // Isolate per directory and per file. `parseCustomPoliciesFromFile` guards + // non-existence but `readFile` still throws on EACCES, a file deleted + // between listing and reading, or a directory entry — and that rejection + // propagates out of the action, where both callers swallow it, leaving the + // Configure Policies tab stuck on "Loading…" with no error. One unreadable + // file must cost that file's policy list, not the whole page. + let files: string[] = []; + try { + files = discoverPolicyFiles(dir); + } catch { + continue; + } + for (const filePath of files) { + let policies: CustomPolicyInfo[] = []; + try { + policies = await parseCustomPoliciesFromFile(filePath); + } catch { + // Still list the file — it IS installed and enforcing; we just cannot + // read its contents to name the policies. + } + out.push({ scope, file: basename(filePath), path: filePath, policies }); + } + } + return out; } async function parseCustomPoliciesFromFile(filePath: string): Promise { @@ -117,6 +199,8 @@ export async function getHooksConfigAction(): Promise { ? await parseCustomPoliciesFromFile(config.customPoliciesPath) : undefined; + const conventionPolicies = await discoverConventionPolicies(); + return { enabledPolicies: config.enabledPolicies, installedScopes, @@ -125,5 +209,6 @@ export async function getHooksConfigAction(): Promise { policies, customPoliciesPath: config.customPoliciesPath, customPolicies: customPolicies?.length ? customPolicies : undefined, + conventionPolicies, }; } diff --git a/app/components/project-list.tsx b/app/components/project-list.tsx index cc37837d..f9cca69e 100644 --- a/app/components/project-list.tsx +++ b/app/components/project-list.tsx @@ -7,7 +7,7 @@ import { useState, useMemo, useEffect, useRef } from "react"; import { ProjectFolder } from "@/lib/projects"; import { CliBadge } from "./cli-badge"; -import { decodeFolderName } from "@/lib/paths"; +import { projectDisplayName } from "@/lib/paths"; import { formatDate } from "@/lib/format-date"; import { FILTER_PRESETS, @@ -24,7 +24,13 @@ import { pageToParam, paramToPage, } from "@/lib/url-filter-serializers"; import { KNOWN_CLI_IDS, getCliLabel, isKnownCli, type CliId } from "@/lib/cli-registry"; -import { Folder, Search, X } from "lucide-react"; +import { + buildProjectTree, + visibleTreeRows, + collapsibleKeys, + type ProjectTreeNode, +} from "@/lib/project-tree"; +import { ChevronDown, ChevronRight, Folder, Search, X } from "lucide-react"; import Link from "next/link"; import PaginationControls from "./pagination-controls"; import DatePickerInput from "./date-picker-input"; @@ -44,6 +50,126 @@ function normalizeKeywordForSearch(keyword: string): string { return keyword.trim().toLowerCase().replace(/\//g, "-"); } +/** Where collapsed folder rows are remembered between visits. */ +const COLLAPSED_STORAGE_KEY = "failproofai.projects.collapsed"; + +function readCollapsed(): string[] { + try { + const raw = window.localStorage.getItem(COLLAPSED_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return Array.isArray(parsed) ? parsed.filter((k) => typeof k === "string") : []; + } catch { + // Private mode / disabled storage / corrupt value — everything stays expanded. + return []; + } +} + +function persistCollapsed(keys: Set): void { + try { + window.localStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify([...keys])); + } catch { + // Storage unavailable — collapsing still works for this session. + } +} + +/** + * One row of the projects table — a leaf project, or a folder grouping the + * gateway CLIs' (profile/agent, channel) hierarchy. + * + * Both shapes render as a `` in the SAME table so the Path and Last + * Modified columns stay aligned down the page; depth becomes left padding on + * the name cell rather than a nested table. + */ +function ProjectRow({ + node, + collapsed, + interactive, + onToggle, +}: { + node: ProjectTreeNode; + collapsed: boolean; + /** False while a search force-expands the tree — see the folder branch below. */ + interactive: boolean; + onToggle: (key: string) => void; +}) { + const isFolder = node.children.length > 0; + const project = node.project; + const Chevron = collapsed ? ChevronRight : ChevronDown; + + // Depth is indentation, capped so a deep gateway path can't push the name off + // a narrow screen. + // + // Applied to the icon cell as well as the name, not just the name: the icon + // lives in its own , so indenting only the name left every child's folder + // icon sitting at the same x as its parent's. The row then read as a flat + // list with ragged text rather than a hierarchy. Shifting both by the same + // amount moves icon and label together, so a child visibly starts to the + // right of its parent's icon. + const indent = { paddingLeft: `${Math.min(node.depth, 4) * 1.25}rem` }; + + return ( + + +
+ +
+ + +
+ {isFolder ? ( + // While a search is active the tree is force-expanded, so a toggle + // could only flip hidden state: it would look interactive, do + // nothing on screen, and silently invert what gets remembered once + // the search is cleared. Render it inert instead. + interactive ? ( + + ) : ( + + + {node.label} + + ) + ) : project ? ( + + {/* Leaves of a gateway tree show only their own segment; a + filesystem project keeps its full decoded path. */} + {node.depth > 0 ? node.label : projectDisplayName(project.name, project.path)} + + ) : ( + {node.label} + )} + {node.cli.map((c) => ( + + ))} + {isFolder && node.sessionCount !== undefined && ( + + {node.sessionCount} session{node.sessionCount === 1 ? "" : "s"} + + )} +
+ + + {project ? project.path : ""} + + + + + + ); +} + export default function ProjectList({ folders }: ProjectListProps) { const url = useUrlParams(); const mountedRef = useRef(false); @@ -127,13 +253,88 @@ export default function ProjectList({ folders }: ProjectListProps) { return filtered.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); }, [normalizedFolders, filterPreset, dateRange, keywords, filterCli]); - const totalPages = Math.max(1, Math.ceil(filteredFolders.length / ITEMS_PER_PAGE)); + // ── Folder tree ── + // + // Built from ALL filtered projects, never from a page slice: a gateway root's + // channels are individual projects interleaved by recency, so slicing first + // would split one profile across two pages — rendering the root twice, each + // time rolling up only the sessions that happened to land on that page, with + // the true total shown nowhere. Grouping is pure presentation; project names, + // and therefore every /project/ link, are untouched. + const tree = useMemo(() => buildProjectTree(filteredFolders), [filteredFolders]); + + // Pagination walks TOP-LEVEL rows. With no gateway CLIs every root is exactly + // one project, so the page size and the reported range are identical to the + // flat table's; a gateway CLI simply folds its channels into one entry. + const totalPages = Math.max(1, Math.ceil(tree.length / ITEMS_PER_PAGE)); useEffect(() => { if (currentPage > totalPages) setCurrentPage(totalPages); }, [currentPage, totalPages, setCurrentPage]); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, filteredFolders.length); - const paginatedFolders = filteredFolders.slice(startIndex, endIndex); + const rootStart = (currentPage - 1) * ITEMS_PER_PAGE; + const rootEnd = Math.min(rootStart + ITEMS_PER_PAGE, tree.length); + const pageRoots = useMemo(() => tree.slice(rootStart, rootEnd), [tree, rootStart, rootEnd]); + + // The summary counts PROJECTS, so derive its range from the projects the + // displayed roots actually cover rather than from the row indices. + const projectsBefore = tree + .slice(0, rootStart) + .reduce((sum, node) => sum + node.projectCount, 0); + const projectsOnPage = pageRoots.reduce((sum, node) => sum + node.projectCount, 0); + const startIndex = projectsBefore; + const endIndex = projectsBefore + projectsOnPage; + + // Collapsed (not expanded) is what we persist: a profile or agent appearing + // for the first time should be visible, not hidden behind a click nobody + // knows to make. Loaded in an effect, never during render — reading + // localStorage while rendering would desync the server-rendered HTML. + const [collapsed, setCollapsed] = useState>(() => new Set()); + useEffect(() => { + setCollapsed(new Set(readCollapsed())); + }, []); + + const toggleCollapsed = (key: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + persistCollapsed(next); + return next; + }); + }; + + // A search that matched something must not leave the match hidden inside a + // collapsed folder, so an active search force-expands everything. + const searching = keywords.length > 0; + const visibleRows = useMemo( + () => visibleTreeRows(pageRoots, (key) => !searching && collapsed.has(key)), + [pageRoots, collapsed, searching], + ); + + // Keys the bulk toggle acts on: everything collapsible in the CURRENT view + // (this page, this filter). It must never be used to REPLACE the stored set — + // see toggleAll. + const pageCollapsibleKeys = useMemo(() => collapsibleKeys(pageRoots), [pageRoots]); + // The label, though, follows what is on SCREEN. Judging it by the whole page's + // keys would keep saying "Collapse all" after a root was collapsed, because + // its hidden descendants are still expanded — a button that then does nothing + // visible. + const visibleCollapsibleKeys = visibleRows.filter((n) => n.children.length > 0).map((n) => n.key); + const anyExpanded = visibleCollapsibleKeys.some((k) => !collapsed.has(k)); + + const toggleAll = () => { + setCollapsed((prev) => { + // MERGE, never replace: the stored set spans every page and filter, so + // assigning this view's keys wholesale would silently discard collapses + // the user made elsewhere. + const next = new Set(prev); + for (const key of pageCollapsibleKeys) { + if (anyExpanded) next.add(key); + else next.delete(key); + } + persistCollapsed(next); + return next; + }); + }; return (
@@ -266,11 +467,20 @@ export default function ProjectList({ folders }: ProjectListProps) {
{/* Results Count */} -
+
+ {pageCollapsibleKeys.length > 0 && !searching && ( + + )} {filteredFolders.length === 0 ? ( - <>No projects found + No projects found ) : ( - <> + Showing {startIndex + 1}-{endIndex} of {filteredFolders.length} projects {filteredFolders.length !== normalizedFolders.length && ( @@ -282,7 +492,7 @@ export default function ProjectList({ folders }: ProjectListProps) { with {keywords.length} keyword{keywords.length !== 1 ? "s" : ""} )} - + )}
@@ -309,44 +519,21 @@ export default function ProjectList({ folders }: ProjectListProps) { - {paginatedFolders.length === 0 ? ( + {visibleRows.length === 0 ? ( No projects found matching the selected filter. ) : ( - paginatedFolders.map((folder) => ( - - - - - -
- - {decodeFolderName(folder.name)} - - {folder.cli.map((c) => ( - - ))} -
- - - {folder.path} - - - - - + visibleRows.map((node) => ( + )) )} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 93dabbc4..09a2e9ce 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -3,18 +3,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; -import { - ShieldCheck, - ShieldX, - ShieldAlert, - Shield, - ChevronDown, - Copy, - Check, - Settings, - Code, - X, -} from "lucide-react"; +import { Check, ChevronDown, Code, Copy, Settings, Shield, ShieldAlert, ShieldCheck, ShieldX, TriangleAlert, X } from "lucide-react"; import PaginationControls from "@/app/components/pagination-controls"; import { getHookActivityAction, searchHookActivityAction } from "@/app/actions/get-hook-activity"; import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; @@ -29,6 +18,7 @@ import { usePostHog } from "@/contexts/PostHogContext"; import { useUrlParams } from "@/lib/use-url-params"; import { pageToParam, paramToPage } from "@/lib/url-filter-serializers"; import { getCliLabel, getCliBadgeClasses, KNOWN_CLI_IDS, isKnownCli, type CliId } from "@/lib/cli-registry"; +import { enforcementFor } from "@/src/hooks/enforcement-capability"; import { formatRelativeTime } from "@/lib/format-duration"; import { Button } from "@/components/ui/button"; @@ -330,6 +320,58 @@ function StatsBar({ stats }: { stats: HookActivityPayload["stats"] }) { // -- Expandable Detail Panel -- + +/** + * "Your policy ran, but this event cannot block on this CLI." + * + * Renders ONLY when the (cli, event) pair is a verified `observe`. A pair we + * have not traced returns undefined from `enforcementFor` and this renders + * nothing — a hedge ("capability unverified") would still be a claim, and + * unverified claims are the failure this whole matrix exists to prevent. + * + * The deny case matters more than the allow case: the row already says + * "denied", the activity store recorded a deny, and telemetry counted one — so + * without this note the UI actively reports enforcement that did not happen. + */ +function EnforcementNote({ + item, +}: { + item: HookActivityPayload["entries"][number]; +}) { + const capability = enforcementFor( + item.integration as IntegrationType | undefined, + item.eventType, + ); + if (capability !== "observe") return null; + + const ran = item.matchedPolicies ?? []; + // Rows written before matchedPolicies existed have no list; naming no policy + // is better than implying none ran. + if (ran.length === 0) return null; + + const cli = item.integration ? getCliLabel(item.integration as IntegrationType) : "This CLI"; + const names = ran.map((n) => n.replace(/^(custom|\.failproofai-(project|user))\//, "")); + const label = names.length === 1 ? names[0] : `${names.length} policies`; + const denied = item.decision === "deny"; + + return ( +
+
+ ); +} + function DetailPanel({ item, }: { @@ -344,6 +386,7 @@ function DetailPanel({ event detail
+
Session ID: @@ -1427,7 +1470,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install {config.enabledPolicies.length} {" / "} - {config.policies.length + (config.customPolicies?.length ?? 0)}{" "} + {config.policies.length + (config.customPolicies?.length ?? 0) + (config.conventionPolicies?.reduce((n, e) => n + e.policies.length, 0) ?? 0)}{" "} policies enabled {installed && ( @@ -1596,6 +1639,58 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install ))}
)} + + {/* Convention policies — discovered from .failproofai/policies/, never + from config, so they are grouped by the file that declares them. */} + {config.conventionPolicies?.map((entry) => ( +
+
+ + Convention Policies — {entry.scope === "project" ? "Project" : "User"} + + + {entry.policies.length} hook{entry.policies.length === 1 ? "" : "s"} + +
+
+ + {entry.path} +
+ {entry.policies.length === 0 ? ( +
+ +

+ No policies could be read from this file. It is still loaded at runtime — + run failproofai policies to see what it registers. +

+
+ ) : ( + entry.policies.map((policy) => ( +
+
+
+ {policy.name} +
+
+ {policy.description && ( + + {policy.description} + + )} + {policy.eventScope && ( + + {policy.eventScope} + + )} +
+
+ )) + )} +
+ ))}
); @@ -1646,7 +1741,10 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: setHooksInstalled(cfg.clis.some((c) => c.installed)); setPolicyCounts({ enabled: cfg.enabledPolicies.length, - total: cfg.policies.length + (cfg.customPolicies?.length ?? 0), + total: + cfg.policies.length + + (cfg.customPolicies?.length ?? 0) + + (cfg.conventionPolicies?.reduce((n, e) => n + e.policies.length, 0) ?? 0), }); setInstalledCliLabels(cfg.clis.filter((c) => c.installed).map((c) => c.label)); }) diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx index dacbcc8a..3478d808 100644 --- a/app/project/[name]/page.tsx +++ b/app/project/[name]/page.tsx @@ -13,7 +13,7 @@ import { getCachedDevinSessionsByEncodedName } from "@/lib/devin-projects"; import { getCachedAntigravitySessionsByEncodedName } from "@/lib/antigravity-projects"; import { getCachedGooseSessionsByEncodedName } from "@/lib/goose-projects"; import { logWarn } from "@/lib/logger"; -import { decodeFolderName } from "@/lib/paths"; +import { decodeFolderName, projectDisplayName, isSyntheticProjectPath } from "@/lib/paths"; import { notFound } from "next/navigation"; import { existsSync } from "fs"; import { stat } from "fs/promises"; @@ -150,7 +150,17 @@ export default async function ProjectPage({ params }: ProjectPageProps) { back to projects -

{canonicalRoot}

+ {/* Gateway projects carry a synthetic `hermes:profile:source` root; show + the same slash form the projects panel does, so the page you land on + is titled what you clicked. Everything else keeps the LITERAL cwd + recovered above — passing it through projectDisplayName would discard + it and re-derive a lossy name from the slug, turning a real + `/home/u/my-app` into `/home/u/my/app`. */} +

+ {isSyntheticProjectPath(canonicalRoot) + ? projectDisplayName(name, canonicalRoot) + : canonicalRoot} +

path diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 43ea85f2..7e921a81 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -24,7 +24,9 @@ The dashboard reads local project, session, and failproofai configuration data d ### Projects -Lists all Claude Code, OpenAI Codex, GitHub Copilot CLI _(beta)_, Cursor Agent _(beta)_, OpenCode _(beta)_, Pi _(beta)_, Hermes, OpenClaw, Factory Droid, Devin, Antigravity, and Goose projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions///
/*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field; Cursor Agent projects are discovered by scanning per-session metadata under `~/.cursor/agent-sessions//` (configurable via `CURSOR_HOME`, with `conversations/` and `sessions/` probed as fallbacks) for a `cwd` scalar in `meta.json` / `session.json` / `workspace.yaml`; OpenCode projects are discovered by querying its SQLite DB at `~/.local/share/opencode/opencode.db` via `opencode db --format json` (we read the `session` and `project` tables and group by `project_id`); Pi projects are discovered by scanning per-session JSONL transcripts under `~/.pi/agent/sessions//_.jsonl` (configurable via `PI_SESSIONS_DIR`) and pulling the `cwd` from each session's first record; Hermes gateway sessions are read directly from its SQLite store at `~/.hermes/state.db` (configurable via `HERMES_DB_PATH`) and grouped into `hermes-` projects by `source` (Slack/Telegram/cli/cron — gateway sessions have no cwd); OpenClaw gateway sessions are read from `~/.openclaw/agents//sessions/*.jsonl` and grouped into `openclaw-` projects (also cwd-less); Factory Droid projects are discovered from the JSONL transcripts at `~/.factory/sessions//*.jsonl` and grouped by cwd; Devin projects from its SQLite DB at `~/.local/share/devin/cli/sessions.db` (grouped by each session's `working_directory`); Antigravity projects from the JSONL transcripts at `~/.gemini/antigravity-cli/brain//…/transcript_full.jsonl` and grouped by cwd; and Goose projects from its SQLite DB at `~/.local/share/goose/sessions/sessions.db` (grouped by each session's `working_dir`). A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose`. +Lists all Claude Code, OpenAI Codex, GitHub Copilot CLI _(beta)_, Cursor Agent _(beta)_, OpenCode _(beta)_, Pi _(beta)_, Hermes, OpenClaw, Factory Droid, Devin, Antigravity, and Goose projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions///
/*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field; Cursor Agent projects are discovered by scanning per-session metadata under `~/.cursor/agent-sessions//` (configurable via `CURSOR_HOME`, with `conversations/` and `sessions/` probed as fallbacks) for a `cwd` scalar in `meta.json` / `session.json` / `workspace.yaml`; OpenCode projects are discovered by querying its SQLite DB at `~/.local/share/opencode/opencode.db` via `opencode db --format json` (we read the `session` and `project` tables and group by `project_id`); Pi projects are discovered by scanning per-session JSONL transcripts under `~/.pi/agent/sessions//_.jsonl` (configurable via `PI_SESSIONS_DIR`) and pulling the `cwd` from each session's first record; Hermes gateway sessions are read directly from the SQLite store of every profile — `~/.hermes/state.db` plus `~/.hermes/profiles//state.db` (overridable via `HERMES_HOME`, or `HERMES_DB_PATH` for a single database) — and grouped into `hermes--` projects by profile and `source` (Slack/Telegram/cli/cron — gateway sessions have no cwd); OpenClaw gateway sessions are read from `~/.openclaw/agents//sessions/*.jsonl` and grouped into `openclaw--` projects by agent and channel (also cwd-less); Factory Droid projects are discovered from the JSONL transcripts at `~/.factory/sessions//*.jsonl` and grouped by cwd; Devin projects from its SQLite DB at `~/.local/share/devin/cli/sessions.db` (grouped by each session's `working_directory`); Antigravity projects from the JSONL transcripts at `~/.gemini/antigravity-cli/brain//…/transcript_full.jsonl` and grouped by cwd; and Goose projects from its SQLite DB at `~/.local/share/goose/sessions/sessions.db` (grouped by each session's `working_dir`). A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose`. + +Hermes and OpenClaw are user-scoped and have no working directory to group by, so they render as a **collapsible folder tree** — profile (or agent) at the top level, its channels beneath — while every cwd-based CLI stays a flat row. Folder rows roll up the session count and most recent activity of everything under them, collapsed folders are remembered between visits, and a keyword search expands whatever it matches. Each project shows: - Project name (derived from the folder path) diff --git a/integration-suite/probe-cli.sh b/integration-suite/probe-cli.sh index adcb70ad..b517601b 100644 --- a/integration-suite/probe-cli.sh +++ b/integration-suite/probe-cli.sh @@ -213,8 +213,13 @@ for _ in $(seq 1 $ATTEMPTS); do denied canary-bash "$LOGA/hooks.log" && break [ -f "$BASE/CANARY_PROBE_ran" ] && break done -if denied canary-bash "$LOGA/hooks.log"; then VA=PASS -elif [ -f "$BASE/CANARY_PROBE_ran" ]; then VA=FAIL +# Ground truth FIRST. A CLI that logs our deny and runs the command anyway is +# the silent-allow this suite exists to catch (copilot 1.0.70 did exactly that) +# — and checking our own hooks.log first scored it PASS, because our log says +# "denied" whether or not the CLI honoured it. The marker file is the only +# evidence of what the CLI actually did, so it decides. +if [ -f "$BASE/CANARY_PROBE_ran" ]; then VA=FAIL +elif denied canary-bash "$LOGA/hooks.log"; then VA=PASS elif is_error "$OUTA"; then VA=ERROR else VA=INCONCLUSIVE; fi @@ -228,8 +233,10 @@ for _ in $(seq 1 $ATTEMPTS); do read_denied "$LOGB/hooks.log" && break printf '%s' "$OUTB" | grep -qF "$MARKER_CONTENT" && break done -if read_denied "$LOGB/hooks.log"; then VB=PASS -elif printf '%s' "$OUTB" | grep -qF "$MARKER_CONTENT"; then VB=FAIL +# Same ordering rule as probe A: the sentinel leaking into the transcript proves +# the read happened, which outranks our own log claiming we denied it. +if printf '%s' "$OUTB" | grep -qF "$MARKER_CONTENT"; then VB=FAIL +elif read_denied "$LOGB/hooks.log"; then VB=PASS elif is_error "$OUTB"; then VB=ERROR else VB=INCONCLUSIVE; fi diff --git a/lib/hermes-profiles.ts b/lib/hermes-profiles.ts new file mode 100644 index 00000000..543e6012 --- /dev/null +++ b/lib/hermes-profiles.ts @@ -0,0 +1,156 @@ +/** + * Hermes profile discovery — shared by BOTH pillars. + * + * A Hermes "profile" is not a column or a flag: it is a whole separate Hermes + * home directory, each with its own `config.yaml`, `.env`, `SOUL.md`, and + * `state.db`. The default profile lives at `~/.hermes`; every other profile at + * `~/.hermes/profiles//`. Selection is `hermes -p `, a generated + * `~/.local/bin/` alias that exports `HERMES_HOME`, or a sticky default + * recorded in `/active_profile`. + * + * Upstream's own contributor guide warns that hardcoding `~/.hermes` breaks + * profiles — which is exactly what both pillars used to do: + * • audit → read one state.db, so non-default profiles were INVISIBLE; + * • enforcement→ wrote one config.yaml, so non-default profiles ran UNHOOKED. + * Both now enumerate through here. + * + * Kept deliberately dependency-light (node:fs / node:os / node:path only) so + * `src/hooks/integrations.ts` can import it without pulling the sql.js reader + * into the hook hot path. + * + * Home override: set `HERMES_HOME` (Hermes's own env var — respected here so a + * profile-scoped shell and failproofai agree on what "all profiles" means). + */ +import { readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +/** Directory under the Hermes root that holds non-default profiles. */ +const PROFILES_DIR = "profiles"; + +/** Name we give the root home (`~/.hermes`), which Hermes itself leaves unnamed. */ +export const HERMES_DEFAULT_PROFILE = "default"; + +export interface HermesProfile { + /** Profile name — `"default"` for the root home, else the directory name. */ + name: string; + /** Absolute HERMES_HOME for this profile (holds config.yaml + state.db). */ + home: string; +} + +/** + * The Hermes ROOT home — the directory that owns `profiles/`. + * + * `HERMES_HOME` may point AT a profile (`/profiles/`), because + * that's what the per-profile alias wrapper exports. We climb back to `` + * in that case so discovery still sees every sibling profile — mirroring what + * upstream does so `profile list` can see them all. + */ +export function hermesRoot(): string { + const env = (process.env.HERMES_HOME || "").trim(); + if (env) { + const home = resolve(env); + const parent = dirname(home); + if (basename(parent) === PROFILES_DIR) return dirname(parent); + return home; + } + return join(homedir(), ".hermes"); +} + +/** + * Every Hermes profile on disk: the root home first (as `"default"`), then each + * `/profiles//` in name order. + * + * Fail-open — a missing or unreadable `profiles/` dir just means "default only", + * which is the single-profile install everyone starts with. + * + * A profile directory literally named `default` would collide with the root's + * reserved name; the root wins and the directory is skipped (dedup by name keeps + * grouping keys unique and the outcome deterministic). + */ +export function listHermesProfiles(): HermesProfile[] { + const root = hermesRoot(); + const out: HermesProfile[] = [{ name: HERMES_DEFAULT_PROFILE, home: root }]; + const seen = new Set([HERMES_DEFAULT_PROFILE]); + + let names: string[]; + try { + names = readdirSync(join(root, PROFILES_DIR), { withFileTypes: true }) + // Symlinked profile dirs are legitimate, and `isDirectory()` is false for them. + .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); + } catch { + return out; + } + + for (const name of names) { + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, home: join(root, PROFILES_DIR, name) }); + } + return out; +} + +// ── (profile, source) naming ──────────────────────────────────────────────── +// +// Gateway sessions have no cwd to group by, so the dashboard and the audit +// synthesize a project per (profile, source). Lives here — next to the profile +// list the parser needs — so lib/hermes-sessions.ts and lib/hermes-projects.ts +// can both use it without importing each other. + +/** + * Encoded project name for a (profile, source) pair. + * + * `decodeFolderName()` renders every `-` as `/`, so `hermes-work-slack` shows up + * as `hermes/work/slack` in the projects panel with no UI change. + */ +export function hermesProjectName(profile: string, source: string): string { + return `hermes-${profile}-${source}`; +} + +/** Machine-readable grouping key, shown under the project name. */ +export function hermesProjectPath(profile: string, source: string): string { + return `hermes:${profile}:${source}`; +} + +export interface HermesNameSplit { + profile: string; + source: string; +} + +/** + * Every way `hermes--` could split, best guess first. + * + * Profile names may contain `-`, so the slug is never split blindly: each + * candidate is a profile that actually exists on disk, tried longest-first. + * Length alone is not decisive though — with profiles `work` and `work-slack`, + * `hermes-work-slack-dev` could be `work-slack`+`dev` or `work`+`slack-dev` — + * so callers walk the candidates and take the first that owns real sessions. + * + * The last candidate is always the legacy `hermes-` form attributed to + * the default profile, keeping links made before profile support alive. + */ +export function hermesProjectNameCandidates(name: string): HermesNameSplit[] { + if (!name.startsWith("hermes-")) return []; + const rest = name.slice("hermes-".length); + if (!rest) return []; + + const out: HermesNameSplit[] = []; + const profiles = listHermesProfiles() + .map((p) => p.name) + .sort((a, b) => b.length - a.length); + for (const profile of profiles) { + const prefix = `${profile}-`; + if (rest.startsWith(prefix) && rest.length > prefix.length) { + out.push({ profile, source: rest.slice(prefix.length) }); + } + } + out.push({ profile: HERMES_DEFAULT_PROFILE, source: rest }); + return out; +} + +/** The best-guess split for a project name — `null` if it isn't a Hermes one. */ +export function parseHermesProjectName(name: string): HermesNameSplit | null { + return hermesProjectNameCandidates(name)[0] ?? null; +} diff --git a/lib/hermes-projects.ts b/lib/hermes-projects.ts index ed55d462..d81395b4 100644 --- a/lib/hermes-projects.ts +++ b/lib/hermes-projects.ts @@ -1,20 +1,32 @@ /** * Hermes (hermes-agent) session enumeration — AUDIT-ONLY. * - * Reads the `sessions` table directly from `~/.hermes/state.db` via the bundled - * sql.js reader. All gateway users' sessions live in the one DB → "audit - * everyone from one place". Because we query the table (not a text `sessions - * list`), we get `source`/`cwd`/`message_count` up front — enough to group by - * channel and to build a real per-transcript cache key. + * Reads the `sessions` table directly from every profile's `state.db` via the + * bundled sql.js reader. All of a profile's gateway users live in its one DB → + * "audit everyone from one place". Because we query the table (not a text + * `sessions list`), we get `source`/`cwd`/`message_count` up front — enough to + * group and to build a real per-transcript cache key. + * + * Sessions are grouped by (profile, source): a profile is a whole separate + * Hermes home (see lib/hermes-profiles.ts), so it is the outer scope, and + * `source` (slack/telegram/cli/cron) splits the channels within it. */ import { openSqliteReadonly } from "./sqlite-reader"; -import { hermesDbPath, epochToMs } from "./hermes-sessions"; +import { hermesDbPaths, epochToMs } from "./hermes-sessions"; +import { + HERMES_DEFAULT_PROFILE, + hermesProjectName, + hermesProjectPath, + hermesProjectNameCandidates, +} from "./hermes-profiles"; import { runtimeCache } from "./runtime-cache"; import type { ProjectFolder, SessionFile } from "./projects"; import { formatDate } from "./format-date"; export interface HermesSessionRef { sessionId: string; + /** Which profile's state.db this row came from (`"default"` for `~/.hermes`). */ + profile: string; source?: string; cwd?: string; title?: string; @@ -52,11 +64,24 @@ interface SessionRow { } /** - * List every Hermes session (all users). Returns `[]` when the DB is missing or - * unreadable (fail-open — the audit just skips Hermes). + * List every Hermes session — every user, of every profile. A missing or + * unreadable profile DB is skipped rather than failing the batch (fail-open: + * the audit degrades to the profiles it can read instead of dropping Hermes). */ export async function getHermesSessions(): Promise { - const db = await openSqliteReadonly(hermesDbPath()); + const out: HermesSessionRef[] = []; + for (const { profile, dbPath } of hermesDbPaths()) { + out.push(...(await readSessionsFromDb(dbPath, profile))); + } + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; +} + +async function readSessionsFromDb( + dbPath: string, + profile: string, +): Promise { + const db = await openSqliteReadonly(dbPath); if (!db) return []; try { const rows = db.query( @@ -69,6 +94,7 @@ export async function getHermesSessions(): Promise { ); return rows.map((r) => ({ sessionId: r.id, + profile, source: r.source ?? undefined, cwd: r.cwd ?? undefined, title: r.title ?? undefined, @@ -92,29 +118,44 @@ export const getCachedHermesSessions = runtimeCache(getHermesSessions, 2); // ── Dashboard history browser (projects list + project-detail sessions) ── /** - * Surface Hermes gateway sessions as synthetic "projects" grouped by `source` - * (slack/telegram/cli/cron) — gateway sessions have no cwd to group by. One - * ProjectFolder per source; its `name` is `hermes-`, reversed in - * `getHermesSessionsByEncodedName`. + * Surface Hermes gateway sessions as synthetic "projects" grouped by + * (profile, source) — gateway sessions have no cwd to group by. One + * ProjectFolder per pair, named by `hermesProjectName`. */ export async function getHermesProjects(): Promise { const sessions = await getHermesSessions(); - const latestBySource = new Map(); + // key → { profile, source, latest }; keeps profile/source intact for naming + // (the key itself is not parseable back when a profile name contains "-"). + const groups = new Map< + string, + { profile: string; source: string; latest: number; count: number } + >(); for (const s of sessions) { if (s.messageCount <= 0 && !s.hasMessages) continue; // skip empty (message_count can lag; trust real messages) - const src = s.source ?? "unknown"; - latestBySource.set(src, Math.max(latestBySource.get(src) ?? 0, s.mtimeMs)); + const profile = s.profile || HERMES_DEFAULT_PROFILE; + const source = s.source ?? "unknown"; + const key = JSON.stringify([profile, source]); + const prev = groups.get(key); + if (prev) { + prev.latest = Math.max(prev.latest, s.mtimeMs); + // Sessions, not messages — `messageCount` counts messages within one + // session and is documented as possibly lagging. + prev.count += 1; + } else { + groups.set(key, { profile, source, latest: s.mtimeMs, count: 1 }); + } } const out: ProjectFolder[] = []; - for (const [src, latest] of latestBySource) { + for (const { profile, source, latest, count } of groups.values()) { const lastModified = new Date(latest); out.push({ - name: `hermes-${src}`, - path: `hermes:${src}`, + name: hermesProjectName(profile, source), + path: hermesProjectPath(profile, source), isDirectory: true, lastModified, lastModifiedFormatted: formatDate(lastModified), cli: ["hermes"], + sessionCount: count, }); } out.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); @@ -127,18 +168,43 @@ export interface HermesProjectByName { } /** - * Resolve the Hermes sessions for a synthetic project name (`hermes-`), - * for the project-detail page. Non-Hermes names resolve to empty. + * Resolve the Hermes sessions for a synthetic project name + * (`hermes--`), for the project-detail page. Non-Hermes names + * resolve to empty. */ export async function getHermesSessionsByEncodedName( name: string, ): Promise { - if (!name.startsWith("hermes-")) return { cwd: null, sessions: [] }; - const source = name.slice("hermes-".length); + const candidates = hermesProjectNameCandidates(name); + if (candidates.length === 0) return { cwd: null, sessions: [] }; + const sessions = await getHermesSessions(); - const matched = sessions.filter((s) => (s.messageCount > 0 || s.hasMessages) && (s.source ?? "unknown") === source); + const matching = (split: { profile: string; source: string }) => + sessions.filter( + (s) => + (s.messageCount > 0 || s.hasMessages) && + (s.profile || HERMES_DEFAULT_PROFILE) === split.profile && + (s.source ?? "unknown") === split.source, + ); + + // First split that actually owns sessions — longest-match alone picks the + // wrong profile when one name is a hyphen-prefix of another. Falling back to + // the best guess keeps the page labelled sensibly when nothing matches. + let chosen = candidates[0]; + let matched = matching(chosen); + if (matched.length === 0) { + for (const candidate of candidates.slice(1)) { + const hits = matching(candidate); + if (hits.length > 0) { + chosen = candidate; + matched = hits; + break; + } + } + } + const { profile, source } = chosen; return { - cwd: `hermes:${source}`, + cwd: hermesProjectPath(profile, source), sessions: matched.map((s) => { const lastModified = new Date(s.mtimeMs); return { diff --git a/lib/hermes-sessions.ts b/lib/hermes-sessions.ts index de742745..c0fbcc0d 100644 --- a/lib/hermes-sessions.ts +++ b/lib/hermes-sessions.ts @@ -12,12 +12,22 @@ * `hermesRowsToLogEntries` pairs them (mirrors `lib/codex-sessions.ts`) and is a * PURE function of the message rows, so it is unit-testable without a DB. * + * PROFILES: Hermes profiles are separate home dirs, each with its OWN state.db + * (`~/.hermes/state.db` for the default, `~/.hermes/profiles//state.db` + * otherwise) — see lib/hermes-profiles.ts. So every read here fans out across + * profiles instead of assuming one DB. + * * DB path override: set `HERMES_DB_PATH` (used by tests and to point at a copied - * or remote state.db). + * or remote state.db) — it collapses discovery to that single file. Set + * `HERMES_HOME` instead to audit a real multi-profile tree. */ -import { homedir } from "node:os"; import { join } from "node:path"; import { openSqliteReadonly } from "./sqlite-reader"; +import { + listHermesProfiles, + hermesProjectPath, + HERMES_DEFAULT_PROFILE, +} from "./hermes-profiles"; import { runtimeCache } from "./runtime-cache"; import { baseEntry, @@ -32,9 +42,26 @@ import { } from "./log-entries"; import { formatDuration } from "./format-duration"; -/** Absolute path to Hermes's SQLite DB (override with HERMES_DB_PATH). */ -export function hermesDbPath(): string { - return process.env.HERMES_DB_PATH || join(homedir(), ".hermes", "state.db"); +/** One Hermes state.db, tagged with the profile that owns it. */ +export interface HermesDb { + profile: string; + dbPath: string; +} + +/** + * Every Hermes state.db to read — one per profile on disk. + * + * `HERMES_DB_PATH` still wins and collapses this to a single synthetic + * `default` profile, so pointing at a copied/remote state.db keeps working + * exactly as before. + */ +export function hermesDbPaths(): HermesDb[] { + const override = process.env.HERMES_DB_PATH; + if (override) return [{ profile: HERMES_DEFAULT_PROFILE, dbPath: override }]; + return listHermesProfiles().map((p) => ({ + profile: p.name, + dbPath: join(p.home, "state.db"), + })); } /** Coerce a Hermes epoch value (seconds or ms) to epoch ms. Hermes stores @@ -210,6 +237,8 @@ export interface HermesSessionLogData { rawLines: Record[]; cwd?: string; filePath: string; // synthetic — hermes keeps sessions in a DB; we use hermes:// + /** Which profile's state.db the session was found in. */ + profile: string; } interface HermesMessageRow { @@ -223,14 +252,33 @@ interface HermesMessageRow { } /** - * Load one session by ID from `state.db`. Returns `null` when the DB is - * unavailable or the session doesn't exist. + * Load one session by ID, searching every profile's `state.db` and returning the + * first hit. Returns `null` when no profile has it. + * + * Searching beats encoding the profile into the path: session ids are + * `YYYYMMDD_HHMMSS_`, so a cross-profile collision is vanishingly + * unlikely, and `hermes://` therefore stays a stable identifier — no + * migration for transcript paths already recorded in audit history, and no + * change to the download route or its id validator. Profile order from + * `hermesDbPaths()` is deterministic, so the tie-break is too. */ export async function getHermesSessionLog( sessionId: string, ): Promise { if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null; - const db = await openSqliteReadonly(hermesDbPath()); + for (const { profile, dbPath } of hermesDbPaths()) { + const found = await loadFromDb(dbPath, profile, sessionId); + if (found) return found; + } + return null; +} + +async function loadFromDb( + dbPath: string, + profile: string, + sessionId: string, +): Promise { + const db = await openSqliteReadonly(dbPath); if (!db) return null; try { const sessionRows = db.query<{ source: string | null; cwd: string | null }>( @@ -250,14 +298,21 @@ export async function getHermesSessionLog( msgRows as unknown as Record[], "session", ); - // Gateway sessions have no cwd → group by source (slack/telegram/cli/cron). + // Gateway sessions have no cwd → fall back to the same (profile, source) + // key the projects panel and the audit adapter group by, so an event's cwd + // and its projectName agree. const cwd = - realCwd && realCwd.length > 0 ? realCwd : source ? `hermes:${source}` : undefined; + realCwd && realCwd.length > 0 + ? realCwd + : source + ? hermesProjectPath(profile, source) + : undefined; return { entries, rawLines: msgRows as unknown as Record[], cwd, filePath: `hermes://${sessionId}`, + profile, }; } catch { return null; diff --git a/lib/openclaw-projects.ts b/lib/openclaw-projects.ts index 18ed6036..a6f4976b 100644 --- a/lib/openclaw-projects.ts +++ b/lib/openclaw-projects.ts @@ -2,7 +2,7 @@ * OpenClaw (openclaw gateway) session enumeration — AUDIT-ONLY. * * Surfaces the on-disk transcripts (agents//sessions/.jsonl) as - * synthetic dashboard "projects" grouped by agentId. The per-agent + * synthetic dashboard "projects" grouped by (agentId, channel). The per-agent * `sessions.json` index maps sessionKey → {sessionId, timestamps}; we read it to * recover the sessionKey (which encodes the channel for gateway sessions) and a * reliable last-activity time. Verified live against openclaw v2026.7.1. @@ -10,7 +10,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { runtimeCache } from "./runtime-cache"; -import { listOpenClawTranscripts, openclawHome } from "./openclaw-sessions"; +import { listOpenClawAgents, listOpenClawTranscripts, openclawHome } from "./openclaw-sessions"; import type { ProjectFolder, SessionFile } from "./projects"; import { formatDate } from "./format-date"; @@ -26,6 +26,8 @@ export interface OpenClawSessionRef { * gateway-metadata columns. */ chatId?: string; chatType?: string; + /** Absolute path to the `.jsonl` transcript this ref came from. */ + transcriptPath: string; mtimeMs: number; sizeBytes: number; } @@ -100,6 +102,7 @@ export async function getOpenClawSessions(): Promise { label: meta?.label, chatType: meta?.chatType, chatId: meta?.chatId, + transcriptPath: t.transcriptPath, mtimeMs: meta?.lastMs ?? t.mtimeMs, sizeBytes: t.sizeBytes, }); @@ -112,28 +115,95 @@ export const getCachedOpenClawSessions = runtimeCache(getOpenClawSessions, 2); // ── Dashboard history browser (projects list + project-detail sessions) ── +/** Encoded project name for an (agent, channel) pair. */ +export function openClawProjectName(agentId: string, channel: string): string { + return `openclaw-${agentId}-${channel}`; +} + +/** Machine-readable grouping key, shown under the project name. */ +export function openClawProjectPath(agentId: string, channel: string): string { + return `openclaw:${agentId}:${channel}`; +} + +export interface OpenClawNameSplit { + agentId: string | null; + channel: string; +} + /** - * Surface OpenClaw sessions as synthetic "projects" grouped by **channel** - * (telegram/slack/…/local) — gateway sessions run per channel, not in a host - * repo. Mirrors Hermes's group-by-source. One ProjectFolder per channel; its - * `name` is `openclaw-`, reversed in `getOpenClawSessionsByEncodedName`. + * Every way `openclaw--` could split, best guess first. + * + * Both an agentId and a channel may contain `-`, so the slug is never split + * blindly: each candidate is an agent id that actually exists on disk, tried + * longest-first. That alone is not sufficient — with agents `main` and + * `main-bot` on disk, `openclaw-main-bot-telegram` could be `main-bot`+`telegram` + * OR `main`+`bot-telegram`, and length alone picks the wrong one whenever the + * shorter agent owns it. So callers walk the candidates and take the first that + * matches real sessions. + * + * The last candidate is always the legacy channel-only `openclaw-` + * form (`agentId: null`), so links shared before agents became part of the name + * keep resolving — to every agent on that channel, which is what they meant. + */ +export function openClawProjectNameCandidates(name: string): OpenClawNameSplit[] { + if (!name.startsWith("openclaw-")) return []; + const rest = name.slice("openclaw-".length); + if (!rest) return []; + + const out: OpenClawNameSplit[] = []; + const agents = listOpenClawAgents().sort((a, b) => b.length - a.length); + for (const agentId of agents) { + const prefix = `${agentId}-`; + if (rest.startsWith(prefix) && rest.length > prefix.length) { + out.push({ agentId, channel: rest.slice(prefix.length) }); + } + } + out.push({ agentId: null, channel: rest }); + return out; +} + +/** The best-guess split for a project name — `null` if it isn't an OpenClaw one. */ +export function parseOpenClawProjectName(name: string): OpenClawNameSplit | null { + return openClawProjectNameCandidates(name)[0] ?? null; +} + +/** + * Surface OpenClaw sessions as synthetic "projects" grouped by **(agent, + * channel)** — gateway sessions have no host repo to group by. + * + * Agent is the OUTER axis because it is the stable one: `agentId` is a + * directory name, whereas `channel` is derived from the LAST channel a session + * used. Grouping by channel alone (as this did before) also collapsed two + * different agents that both talk on Telegram into one row with one mixed + * session list — and disagreed with the audit adapter, which grouped by agent. */ export async function getOpenClawProjects(): Promise { const sessions = await getOpenClawSessions(); - const latestByChannel = new Map(); + const groups = new Map< + string, + { agentId: string; channel: string; latest: number; count: number } + >(); for (const s of sessions) { - latestByChannel.set(s.channel, Math.max(latestByChannel.get(s.channel) ?? 0, s.mtimeMs)); + const key = JSON.stringify([s.agentId, s.channel]); + const prev = groups.get(key); + if (prev) { + prev.latest = Math.max(prev.latest, s.mtimeMs); + prev.count += 1; + } else { + groups.set(key, { agentId: s.agentId, channel: s.channel, latest: s.mtimeMs, count: 1 }); + } } const out: ProjectFolder[] = []; - for (const [channel, latest] of latestByChannel) { + for (const { agentId, channel, latest, count } of groups.values()) { const lastModified = new Date(latest); out.push({ - name: `openclaw-${channel}`, - path: `openclaw:${channel}`, + name: openClawProjectName(agentId, channel), + path: openClawProjectPath(agentId, channel), isDirectory: true, lastModified, lastModifiedFormatted: formatDate(lastModified), cli: ["openclaw"], + sessionCount: count, }); } out.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); @@ -146,18 +216,41 @@ export interface OpenClawProjectByName { } /** Resolve the OpenClaw sessions for a synthetic project name - * (`openclaw-`), for the project-detail page. Session names use the - * human-readable `origin.label` (e.g. "Chetan (@chhhee10) id:…") rather than - * the raw session key. */ + * (`openclaw--`), for the project-detail page. Session names + * use the human-readable `origin.label` (e.g. "Chetan (@chhhee10) id:…") + * rather than the raw session key. */ export async function getOpenClawSessionsByEncodedName( name: string, ): Promise { - if (!name.startsWith("openclaw-")) return { cwd: null, sessions: [] }; - const channel = name.slice("openclaw-".length); + const candidates = openClawProjectNameCandidates(name); + if (candidates.length === 0) return { cwd: null, sessions: [] }; + const sessions = await getOpenClawSessions(); - const matched = sessions.filter((s) => s.channel === channel); + const matching = (split: OpenClawNameSplit) => + sessions.filter( + (s) => s.channel === split.channel && (split.agentId === null || s.agentId === split.agentId), + ); + + // Take the first split that actually owns sessions — length alone picks the + // wrong agent when one id is a hyphen-prefix of another. Falling back to the + // best guess keeps the page's labelling sensible when nothing matches. + let chosen = candidates[0]; + let matched = matching(chosen); + if (matched.length === 0) { + for (const candidate of candidates.slice(1)) { + const hits = matching(candidate); + if (hits.length > 0) { + chosen = candidate; + matched = hits; + break; + } + } + } + const { agentId, channel } = chosen; return { - cwd: `openclaw:${channel}`, + // Legacy channel-only names keep their old cwd label, since they really do + // span every agent on that channel. + cwd: agentId === null ? `openclaw:${channel}` : openClawProjectPath(agentId, channel), sessions: matched.map((s) => { const lastModified = new Date(s.mtimeMs); return { diff --git a/lib/openclaw-sessions.ts b/lib/openclaw-sessions.ts index fdebd2d3..afc81b35 100644 --- a/lib/openclaw-sessions.ts +++ b/lib/openclaw-sessions.ts @@ -195,6 +195,26 @@ export interface OpenClawTranscriptFile { sizeBytes: number; } +/** + * Agent ids configured under this OpenClaw home, in name order. + * + * An agentId is just a directory name (`main`, `weather-bot`) — OpenClaw + * validates nothing, so neither do we. Cheap on purpose: one readdir, no + * statting of transcripts, because the project-name parser calls this to + * longest-match an agent id out of a URL slug (agent ids may contain `-`, so + * splitting the slug is not an option). + */ +export function listOpenClawAgents(): string[] { + try { + return readdirSync(join(openclawHome(), "agents"), { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith(".")) + .map((d) => d.name) + .sort(); + } catch { + return []; + } +} + /** Enumerate `agents//sessions/.jsonl` transcripts, skipping the * heavy `.trajectory.jsonl` OTel traces and pointer files. */ export function listOpenClawTranscripts(): OpenClawTranscriptFile[] { diff --git a/lib/paths.ts b/lib/paths.ts index ac2b6e23..6e8de5c3 100644 --- a/lib/paths.ts +++ b/lib/paths.ts @@ -29,6 +29,42 @@ export function decodeFolderName(name: string): string { return name.replace(/-/g, "/"); } +/** + * Synthetic project path for gateway CLIs with no working directory — + * `hermes::`, `openclaw:`. Two or more leading letters + * so a Windows drive letter (`c:/code`) can never match. + */ +const SYNTHETIC_PROJECT_PATH = /^[a-z]{2,}:/; + +/** + * Whether a project `path` is a synthetic gateway path rather than a real + * filesystem path — `hermes::`, `openclaw::`. + * + * These are the only projects with a meaningful segment hierarchy, so this is + * also what decides which rows the projects panel renders as a folder tree. + */ +export function isSyntheticProjectPath(path?: string): boolean { + return !!path && SYNTHETIC_PROJECT_PATH.test(path); +} + +/** Segments of a synthetic gateway path, outermost first. `[]` for real paths. */ +export function syntheticPathSegments(path?: string): string[] { + return isSyntheticProjectPath(path) ? path!.split(":") : []; +} + +/** + * Label to display for a project card. + * + * Filesystem-backed projects decode their folder name. Gateway projects must + * NOT: `decodeFolderName` turns every `-` into `/`, which splits a Hermes + * profile named `my-bot` into `my/bot`. Their `path` already carries the + * segments explicitly, so use it. + */ +export function projectDisplayName(name: string, path?: string): string { + if (isSyntheticProjectPath(path)) return path!.replace(/:/g, "/"); + return decodeFolderName(name); +} + /** * Encodes a filesystem path into a Claude-compatible project folder name. * Inverse of `decodeFolderName`. diff --git a/lib/project-tree.ts b/lib/project-tree.ts new file mode 100644 index 00000000..0af5781e --- /dev/null +++ b/lib/project-tree.ts @@ -0,0 +1,193 @@ +/** + * Folder tree for the projects panel. + * + * Gateway CLIs (Hermes, OpenClaw) are USER-scoped: their sessions have no + * working directory, so they can't sit in the cwd-based project model the other + * CLIs use. What they have instead is a hierarchy — which profile/agent, then + * which channel — and that hierarchy is already encoded in the synthetic + * `path` those producers emit (`hermes:work:slack`, + * `openclaw:weather-bot:telegram`). + * + * So this is a pure PRESENTATION transform over the flat `ProjectFolder[]` the + * server already returns: split those paths into nodes, leave every real + * filesystem project as a top-level leaf. Nothing about project `name`s + * changes, which is what keeps existing `/project/` links, bookmarks and + * stored audit history resolving. + * + * Pure and dependency-free on purpose — `import type` only, so the client + * bundle never pulls in `lib/projects.ts`'s node:fs imports. + */ +import type { ProjectCli, ProjectFolder } from "./projects"; +import { syntheticPathSegments } from "./paths"; + +export interface ProjectTreeNode { + /** Colon-joined segment path (`hermes:work`) — stable across renders, used as + * the React key and as the collapsed-state key. */ + key: string; + /** The single segment this row shows (`work`), not the whole path. */ + label: string; + /** 0 for top-level rows; each level below adds 1 (drives indentation). */ + depth: number; + children: ProjectTreeNode[]; + /** The real project, on rows that map to one. Leaves always have it; a folder + * row has it only if a project exists at exactly that path. Rows without one + * are pure grouping and must not link anywhere. */ + project?: ProjectFolder; + /** Sessions beneath this row, summed. `undefined` when nothing underneath + * reported a count — rendered as nothing rather than as `0`, since the two + * mean very different things. */ + sessionCount?: number; + /** How many real projects this row covers (1 for a leaf). Lets the caller + * paginate over top-level rows while still reporting a project range. */ + projectCount: number; + /** Newest activity anywhere beneath this row. */ + lastModified: Date; + lastModifiedFormatted?: string; + /** Union of the CLIs beneath this row, in first-seen order. */ + cli: ProjectCli[]; +} + +interface Builder { + key: string; + label: string; + depth: number; + children: Map; + project?: ProjectFolder; + sessionCount?: number; + projectCount: number; + lastModified: Date; + lastModifiedFormatted?: string; + cli: string[]; +} + +function makeBuilder(key: string, label: string, depth: number): Builder { + return { + key, + label, + depth, + children: new Map(), + projectCount: 0, + lastModified: new Date(0), + cli: [], + }; +} + +/** Fold a project's stats into a node — newest date wins, counts add, CLIs union. */ +function absorb(node: Builder, folder: ProjectFolder): void { + if (folder.lastModified.getTime() > node.lastModified.getTime()) { + node.lastModified = folder.lastModified; + node.lastModifiedFormatted = folder.lastModifiedFormatted; + } + if (typeof folder.sessionCount === "number") { + node.sessionCount = (node.sessionCount ?? 0) + folder.sessionCount; + } + node.projectCount += 1; + for (const c of folder.cli) if (!node.cli.includes(c)) node.cli.push(c); +} + +function finalize(node: Builder): ProjectTreeNode { + const children = [...node.children.values()] + .map(finalize) + .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return { + key: node.key, + label: node.label, + depth: node.depth, + children, + project: node.project, + sessionCount: node.sessionCount, + projectCount: node.projectCount, + lastModified: node.lastModified, + lastModifiedFormatted: node.lastModifiedFormatted, + cli: node.cli as ProjectCli[], + }; +} + +/** + * Build the render tree for a list of projects (already filtered, sorted and + * paginated by the caller — this adds no policy of its own). + * + * Real filesystem projects become depth-0 leaves in input order-by-recency, + * exactly as the flat table showed them. Gateway projects nest under one node + * per path segment. + */ +export function buildProjectTree(folders: ProjectFolder[]): ProjectTreeNode[] { + const roots = new Map(); + + for (const folder of folders) { + const segments = syntheticPathSegments(folder.path); + + if (segments.length === 0) { + // Real filesystem project — a top-level leaf, keyed by name so two + // projects with an identical path (or none) can't collide. + const node = makeBuilder(`project:${folder.name}`, folder.name, 0); + node.project = folder; + absorb(node, folder); + roots.set(node.key, node); + continue; + } + + let level = roots; + let key = ""; + let node: Builder | undefined; + for (let depth = 0; depth < segments.length; depth++) { + const label = segments[depth]; + key = depth === 0 ? label : `${key}:${label}`; + let next = level.get(key); + if (!next) { + next = makeBuilder(key, label, depth); + level.set(key, next); + } + // Every ancestor rolls up the project's stats, so a collapsed folder still + // reports the freshest activity and total sessions underneath it. + absorb(next, folder); + node = next; + level = next.children; + } + // The deepest segment IS this project. Guard against two projects claiming + // the same path (shouldn't happen post-merge, but silently losing one would + // be worse than keeping the first). + if (node && !node.project) node.project = folder; + } + + return [...roots.values()] + .map(finalize) + .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); +} + +/** + * Flatten the tree to the rows actually rendered, honouring collapsed state. + * + * We track COLLAPSED rather than expanded keys deliberately: a profile or agent + * that appears for the first time should be visible, not hidden behind a click + * the user doesn't know to make. + */ +export function visibleTreeRows( + nodes: ProjectTreeNode[], + isCollapsed: (key: string) => boolean, +): ProjectTreeNode[] { + const out: ProjectTreeNode[] = []; + const walk = (list: ProjectTreeNode[]) => { + for (const node of list) { + out.push(node); + if (node.children.length > 0 && !isCollapsed(node.key)) walk(node.children); + } + }; + walk(nodes); + return out; +} + +/** Keys of every node that has children — i.e. every collapsible row. */ +export function collapsibleKeys(nodes: ProjectTreeNode[]): string[] { + const out: string[] = []; + const walk = (list: ProjectTreeNode[]) => { + for (const node of list) { + if (node.children.length > 0) { + out.push(node.key); + walk(node.children); + } + } + }; + walk(nodes); + return out; +} diff --git a/lib/projects.ts b/lib/projects.ts index fa1afeb4..3471be72 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -29,6 +29,20 @@ export interface ProjectFolder { * the same cwd has transcripts from more than one CLI; rendered as badges. */ cli: ProjectCli[]; + /** + * How many sessions this project holds. + * + * Only the GATEWAY producers (Hermes, OpenClaw) fill this in, because only + * they render as a folder tree where a collapsed row has to say how much is + * underneath it — and for them the count is free, since their grouping loop + * already visits every session. The cwd-based CLIs leave it undefined and the + * UI renders nothing, rather than a `0` that would read as "empty". + * + * Deliberately NOT filled for Claude: counting there needs an extra readdir + * per project folder, and a number that exists for some CLIs and not others + * would sum to a wrong-but-plausible total on a merged row. + */ + sessionCount?: number; } export interface SessionFile { @@ -126,6 +140,14 @@ function mergeProjectFolders(...sources: ProjectFolder[][]): ProjectFolder[] { cli: mergedCli, lastModified: newer.lastModified, lastModifiedFormatted: newer.lastModifiedFormatted, + // Spreading `existing` alone would silently keep only the FIRST source's + // count for a name several CLIs contribute to, so sum explicitly. Stays + // undefined unless at least one side reported a count — `0` would claim + // "no sessions" for producers that simply don't count. + sessionCount: + existing.sessionCount === undefined && f.sessionCount === undefined + ? undefined + : (existing.sessionCount ?? 0) + (f.sessionCount ?? 0), }); } } diff --git a/pi-extension/index.ts b/pi-extension/index.ts index ca50be12..e3bde732 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -347,6 +347,10 @@ export default function failproofaiBridge(pi: PiExtensionApi) { cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); + // `ToolCallEventResult` is `{block?, reason?}` (extensions/types.d.ts:766). + // agent-loop.ts branches on `beforeResult?.block`, so this is the shape that + // stops the tool. Do NOT put the user_bash full-replacement shape here — it + // has no `block` field, so the call would sail straight through. if (decision.block) return { block: true, reason: decision.reason }; return undefined; }); @@ -361,12 +365,38 @@ export default function failproofaiBridge(pi: PiExtensionApi) { cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", }); - if (decision.block) return { block: true, reason: decision.reason }; + // `UserBashEventResult` is `{operations?, result?: BashResult}` — there is + // no `block` field, so the `{block:true}` we used to return matched nothing + // and the command ran anyway. The supported way to stop it is a FULL + // REPLACEMENT result: the extension declares it handled execution, so Pi + // uses this instead of running the command. Verified against the installed + // pi-coding-agent d.ts (extensions/types.d.ts:772, core/bash-executor.d.ts:15). + if (decision.block) { + return { + result: { + output: decision.reason ?? "Blocked by failproofai", + exitCode: 1, + cancelled: false, + truncated: false, + }, + }; + } return undefined; }); - // input → UserPromptSubmit. Honor block decisions if Pi accepts them - // (Pi's docs describe block on input but it's not exhaustively tested). + // input → UserPromptSubmit. + // + // `InputEventResult` is `{action:"continue"|"transform"|"handled"}` + // (extensions/types.d.ts:629) — there is NO `block` field, so the + // `{block:true}` we used to return matched neither branch and every prompt + // was submitted regardless of the verdict. `{action:"handled"}` is the only + // stop: it tells Pi an extension consumed the input, so nothing is sent to + // the model. + // + // Known UX limitation: `handled` drops the prompt silently — Pi shows the + // user nothing. We log the reason to stderr so it is at least recoverable + // from the session output. Pi has no first-class channel for explaining a + // refusal at this event; if one appears, prefer it over the silent drop. pi.on("input", (event: unknown): unknown => { const e = event as PiInputEvent; const decision = callPolicy("input", { @@ -375,8 +405,11 @@ export default function failproofaiBridge(pi: PiExtensionApi) { cwd: resolveCwd(e.cwd), hook_event_name: "UserPromptSubmit", }); - if (decision.block) return { block: true, reason: decision.reason }; - return undefined; + if (decision.block) { + console.error(`[failproofai] prompt blocked: ${decision.reason ?? "blocked by policy"}`); + return { action: "handled" }; + } + return { action: "continue" }; }); // session_start → SessionStart. Observe-only; we still forward so the diff --git a/scripts/launch.ts b/scripts/launch.ts index 456a3543..1ed00e66 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -89,6 +89,13 @@ export function launch(mode: "dev" | "start"): void { stdio: filterLogs ? ["inherit", "pipe", "pipe"] : "inherit", env: { ...process.env, + // `.next/standalone/server.js` does `process.chdir(__dirname)` on its + // first line, so by the time a server action runs, `process.cwd()` is the + // PACKAGE directory, not the directory the user launched from. Anything + // resolving project-scope paths from cwd would look inside the installed + // package and find nothing. Capture the real cwd here, before the child + // starts, the same way FAILPROOFAI_PACKAGE_ROOT is threaded through. + FAILPROOFAI_LAUNCH_CWD: process.env.FAILPROOFAI_LAUNCH_CWD ?? process.cwd(), ...(filterLogs ? { FORCE_COLOR: process.env.FORCE_COLOR ?? "1" } : {}), ...(loggingLevel ? { FAILPROOFAI_LOG_LEVEL: loggingLevel } : {}), ...(disableTelemetry ? { FAILPROOFAI_TELEMETRY_DISABLED: "1" } : {}), diff --git a/src/audit/cli-adapters/hermes.ts b/src/audit/cli-adapters/hermes.ts index 1658e40b..d3ed2d50 100644 --- a/src/audit/cli-adapters/hermes.ts +++ b/src/audit/cli-adapters/hermes.ts @@ -8,9 +8,11 @@ * `logEntriesToEvents` handles the rest. * * Gateway sessions have no `cwd` (Slack/Telegram runs aren't in a repo), so they - * group by `source` (slack/telegram/cli/cron) instead of working directory. + * group by (profile, `source`) instead of working directory — a Hermes profile + * is a whole separate home dir with its own state.db (lib/hermes-profiles.ts). */ import { getHermesSessions } from "../../../lib/hermes-projects"; +import { hermesProjectPath } from "../../../lib/hermes-profiles"; import { getHermesSessionLog } from "../../../lib/hermes-sessions"; import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; import type { ListOpts } from "./claude"; @@ -31,8 +33,9 @@ export async function listHermesTranscriptMetadata( if (s.messageCount <= 0 && !s.hasMessages) continue; // empty → no events (message_count can lag; trust real messages) out.push({ cli: "hermes", - // Group by channel; gateway sessions are cwd-less. - projectName: s.source ? `hermes:${s.source}` : "hermes", + // Group by (profile, channel); gateway sessions are cwd-less, and each + // profile is a separate Hermes home with its own state.db. + projectName: s.source ? hermesProjectPath(s.profile, s.source) : `hermes:${s.profile}`, sessionId: s.sessionId, transcriptPath: `hermes://${s.sessionId}`, mtimeMs: s.mtimeMs, diff --git a/src/audit/cli-adapters/openclaw.ts b/src/audit/cli-adapters/openclaw.ts index 01bd1dd9..5b001e32 100644 --- a/src/audit/cli-adapters/openclaw.ts +++ b/src/audit/cli-adapters/openclaw.ts @@ -7,13 +7,13 @@ * shared LogEntry[] shape, so `logEntriesToEvents` handles the rest. * * Gateway sessions run in the container workspace, not a host repo, so they - * group by agentId (`openclaw:`) and contribute nothing to a - * cwd-scoped audit. + * group by (agentId, channel) — `openclaw::` — and contribute + * nothing to a cwd-scoped audit. That is the SAME key the dashboard groups by; + * the two used to disagree (adapter by agent, panel by channel), so a report and + * the projects panel sliced identical data on different axes. */ -import { - listOpenClawTranscripts, - getOpenClawSessionLog, -} from "../../../lib/openclaw-sessions"; +import { getOpenClawSessions, openClawProjectPath } from "../../../lib/openclaw-projects"; +import { getOpenClawSessionLog } from "../../../lib/openclaw-sessions"; import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; import type { ListOpts } from "./claude"; import { logEntriesToEvents } from "./shared"; @@ -28,15 +28,19 @@ export async function listOpenClawTranscriptMetadata( const sinceMs = opts.sinceMs ?? 0; const out: TranscriptMetadata[] = []; - for (const t of listOpenClawTranscripts()) { - if (t.mtimeMs < sinceMs) continue; + // getOpenClawSessions layers the per-agent sessions.json index over the same + // transcript walk, which is what carries `channel`. It reads one small index + // file per AGENT (not per session) and is memoized, so grouping by channel + // costs no meaningful extra IO on the audit path. + for (const s of await getOpenClawSessions()) { + if (s.mtimeMs < sinceMs) continue; out.push({ cli: "openclaw", - projectName: `openclaw:${t.agentId}`, - sessionId: t.sessionId, - transcriptPath: t.transcriptPath, - mtimeMs: t.mtimeMs, - sizeBytes: t.sizeBytes, + projectName: openClawProjectPath(s.agentId, s.channel), + sessionId: s.sessionId, + transcriptPath: s.transcriptPath, + mtimeMs: s.mtimeMs, + sizeBytes: s.sizeBytes, }); } return out; diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index acdaae9d..d3d556ee 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -29,6 +29,8 @@ import { import { detectInstalledClis, getIntegration, + settingsPathsFor, + unhookedHermesProfiles, } from "./integrations"; import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types"; import { installHooks } from "./manager"; @@ -126,12 +128,28 @@ export function buildAgentChoices(scope: HookScope, cwd: string): MultiChoice 0) { + partialHint = `${unhooked.length} unhooked profile(s): ${unhooked.join(", ")}`; + } + } catch { + // Profile discovery is best-effort — never block the wizard. + } + } return { label: integration.displayName, value: id, checked: isDetected || installedHere, section: isDetected ? "Detected" : "Not installed · set up ahead of time", - hint: installedHere ? "already configured" : isDetected ? undefined : "not on PATH", + hint: installedHere + ? "already configured" + : (partialHint ?? (isDetected ? undefined : "not on PATH")), }; }); } @@ -322,7 +340,11 @@ export function reviewLines(state: { lines.push(" This will update:"); for (const cli of clis) { const integration = getIntegration(cli); - lines.push(` ${homeify(integration.getSettingsPath(scope, cwd))} ${integration.displayName} hooks`); + // Usually one path; Hermes lists one per profile so the operator sees every + // home dir that is about to be written. + for (const p of settingsPathsFor(integration, scope, cwd)) { + lines.push(` ${homeify(p)} ${integration.displayName} hooks`); + } } lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`); return lines; diff --git a/src/hooks/custom-hooks-loader.ts b/src/hooks/custom-hooks-loader.ts index 7c2023a0..8a8b2c83 100644 --- a/src/hooks/custom-hooks-loader.ts +++ b/src/hooks/custom-hooks-loader.ts @@ -27,6 +27,14 @@ const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__"; /** Regex matching convention policy filenames: *policies.{js,mjs,ts} */ const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/; +/** + * Monotonic counter used to cache-bust each dynamic import. Loading N files + * creates N module instances rather than reusing one; every caller here is + * either a one-shot CLI command or a short-lived hook process, so the + * lifetime cost is bounded by the process, not by uptime. + */ +let loadSequence = 0; + /** Script extensions we could load, used to spot near-miss filenames. */ const LOADABLE_EXT_RE = /\.(js|mjs|ts)$/; @@ -96,7 +104,19 @@ async function loadSingleFile( tmpFiles = await rewriteFileTree(absPath, distUrl, distIndex); const entryTmp = absPath + TMP_SUFFIX; - const fileUrl = pathToFileURL(entryTmp).href; + // Cache-bust the specifier. NOTE this works under Node but NOT under Bun, + // which caches by resolved path and ignores the query — and the shipped + // binary runs under Bun. It is kept because it makes repeat loads behave + // the same on both runtimes where it can, but nothing may DEPEND on a + // repeat load re-executing; callers dedupe paths instead (see above). + // The temp path is deterministic, so a second + // load of the same file inside one process hit the ESM module cache, the + // module body never re-ran, no `customPolicies.add` fired, and the caller + // saw zero hooks — indistinguishable from a broken policy file. That is + // exactly what `failproofai policies` rendered as "failed to load" for + // every file when it walked a shared project/user directory twice. + // A distinct query string makes it a distinct module to the loader. + const fileUrl = `${pathToFileURL(entryTmp).href}?v=${++loadSequence}`; await import(/* webpackIgnore: true */ fileUrl); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -201,12 +221,20 @@ export async function loadAllCustomHooks( // purpose, so switching off *discovery* shouldn't silently drop it too. const conventionEnabled = opts?.customPoliciesEnabled !== false; + // Every file already imported this call, by resolved absolute path. Loading + // one twice is never right: `customPolicies.add` is an unconditional push, so + // the second import registers every hook a second time and the policy runs + // twice per event. Seeded by the explicit path below and consulted by both + // convention passes. + const loadedPaths = new Set(); + // 1. Explicit customPoliciesPath (existing behavior) if (customPoliciesPath) { const absPath = isAbsolute(customPoliciesPath) ? customPoliciesPath : resolve(projectRoot, customPoliciesPath); if (existsSync(absPath)) { + loadedPaths.add(absPath); await loadSingleFile(absPath); } else { hookLogWarn(`customPoliciesPath not found: ${absPath}`); @@ -216,10 +244,19 @@ export async function loadAllCustomHooks( const hooksBeforeConvention = getCustomHooks().length; // 2. Project convention: {projectRoot}/.failproofai/policies/*policies.{js,mjs,ts} + // + // A `customPoliciesPath` pointing INTO this directory at a file whose name + // also matches the convention (`*policies.{js,mjs,ts}`) is an ordinary setup — + // `failproofai policies -i -c .failproofai/policies/my-policies.mjs` produces + // exactly that — and it is discovered here as well. Skip what step 1 loaded, + // or the file is imported twice and every hook in it fires twice per event. const projectDir = resolve(projectRoot, ".failproofai", "policies"); if (conventionEnabled) warnSkippedPolicyFiles(projectDir, "project"); - const projectFiles = conventionEnabled ? discoverPolicyFiles(projectDir) : []; + const projectFiles = conventionEnabled + ? discoverPolicyFiles(projectDir).filter((f) => !loadedPaths.has(f)) + : []; for (const file of projectFiles) { + loadedPaths.add(file); const hooksBefore = getCustomHooks().length; await loadSingleFile(file, { conventionScope: "project" }); const newHooks = getCustomHooks().slice(hooksBefore); @@ -233,10 +270,27 @@ export async function loadAllCustomHooks( } // 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts} + // + // Skipped entirely when it resolves to the directory already loaded above — + // which happens whenever the project root IS the home directory, a normal + // setup for a gateway. Without this, every file is loaded a second time and + // `customPolicies.add` (an unconditional push) registers each hook twice, so + // every policy fires twice per event. For a counting policy that means the + // count doubles and its ceiling trips at half the real number. + // + // Today that is masked by the runtime rather than prevented: Bun caches + // dynamic imports by resolved path and IGNORES the `?v=` cache-buster below, + // so the second import is a no-op — but Node honours the query and would + // double-register. The binary runs under Bun and the tests under Node, so the + // bug is invisible from both sides. Do not rely on that; dedupe the paths. const userDir = resolve(homedir(), ".failproofai", "policies"); - if (conventionEnabled) warnSkippedPolicyFiles(userDir, "user"); - const userFiles = conventionEnabled ? discoverPolicyFiles(userDir) : []; + if (conventionEnabled && userDir !== projectDir) warnSkippedPolicyFiles(userDir, "user"); + const userFiles = + conventionEnabled && userDir !== projectDir + ? discoverPolicyFiles(userDir).filter((f) => !loadedPaths.has(f)) + : []; for (const file of userFiles) { + loadedPaths.add(file); const hooksBefore = getCustomHooks().length; await loadSingleFile(file, { conventionScope: "user" }); const newHooks = getCustomHooks().slice(hooksBefore); diff --git a/src/hooks/enforcement-capability.ts b/src/hooks/enforcement-capability.ts new file mode 100644 index 00000000..0799f981 --- /dev/null +++ b/src/hooks/enforcement-capability.ts @@ -0,0 +1,266 @@ +/** + * Does a policy DENY on this (cli, event) pair actually change the agent's + * behaviour, given the wire shape failproofai emits TODAY? + * + * This exists because the same claim used to live in prose, and prose drifted: + * CLAUDE.md documented Hermes `subagent_stop` as a working gate while upstream + * discarded its return, so every customer policy on that event enforced + * nothing, silently, for months. A table a test can assert against cannot rot + * that way. + * + * ABSENT MEANS UNKNOWN, NOT "block". Only rows traced to a consuming call site, + * an official contract, or a recorded live probe appear here. Callers must + * treat a missing entry as "say nothing" — a hedge rendered in the UI is still + * a claim, and an unverified claim is what this file exists to prevent. + */ +// src/hooks/enforcement-capability.ts +// +// Does a policy DENY on this (cli, event) pair actually change the agent's +// behaviour, given the wire shape failproofai emits TODAY? +// +// "block" — the verdict is read at a call site that prevents the action +// or forces the agent to continue/retry. +// "observe" — the action proceeds regardless. Either the CLI discards the +// verdict, or the tool already ran, or the CLI *could* block but +// our shape is inert (each such line says so). +// ABSENT — NOT VERIFIED. The UI must stay silent, never assume "block". +// +// Only SOURCE / VENDOR_DOCS / LIVE_PROBE rows appear here. Every line carries +// its evidence. Versions probed: claude 2.1.220 · codex fe01054a · copilot +// 1.0.71 (some sites re-read in 1.0.68) · cursor-agent 2026.07.16-899851b · +// opencode 7565e035 (v1.18.9, re-checked at v1.14.33) · pi 0.80.10 (source +// 0.82.1) · hermes-agent 5771a6e · openclaw f8ed8ecf (v2026.7.2) · droid +// 0.175.1 · devin 3000.2.17 · agy 1.1.8 · goose 1.43.0. +import type { HookEventType, IntegrationType } from "./types"; + +export type EnforcementCapability = "block" | "observe"; + +export const ENFORCEMENT_CAPABILITY: Record< + IntegrationType, + Partial> +> = { + // ── claude ──────────────────────────────────────────────────────────────── + // Closed source, but the shipped launcher embeds its minified JS as plaintext. + // Offsets are BYTE offsets into the shipped claude 2.1.220 launcher bundle. + // The hook RUNNER is event-agnostic (exit 2 -> blockingError @259223339); all + // asymmetry lives in the ~29 call sites that read or ignore it. + claude: { + PreToolUse: "block", // @256826565 gan() -> behavior==="deny" short-circuits the permission pipeline; tool.call never reached (@256827907) + UserPromptSubmit: "block", // @262298150 blockingError -> {shouldQuery:false} early return; prompt never sent to the model + PermissionRequest: "block", // @255767400 runHooks reads permissionRequestResult -> buildDeny. NOTE exit 2 is IGNORED here (docs are wrong); we emit hookSpecificOutput.decision.behavior:"deny" which IS the honoured shape — do not "simplify" to exit 2 + Stop: "block", // @254572503 blockingErrors -> stopHookActive + `continue` re-enters the loop. CAP: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (default 8); discarded on end-turn paths (@254511900) + SubagentStop: "block", // @259180818 VEe emits SubagentStop when agentId set -> same consumer @254572503. Same cap + same end-turn discard + PreCompact: "block", // MEe @259170280 blockedBy -> consumers @254317851 / @254328121 return early; compaction does not run + PostToolBatch: "block", // @254577316 -> yields hook_stopped_continuation and RETURNS from the query generator. Tools in the batch already ran + ConfigChange: "block", // slt @259171700 -> consumer @251465990 skips the reload. CAVEAT: source==="policy_settings" is force-unblocked (MDM immune) + UserPromptExpansion: "block", // producer @254678081, real consumer @254672418 `if("blocked" in C) return C.blocked`. Slash-command / MCP-prompt only + TaskCreated: "block", // TaskCreate.call @256302400: blockingError -> task deleted (yAo) + tool throws + TaskCompleted: "block", // @254518449 -> same honoured consumer @254572503. GATE: teammate mode only (oy() @249372028) + TeammateIdle: "block", // @254518714 -> same consumer @254572503. Same teammate-mode gate + Elicitation: "block", // oOt @259172011 -> $_o @253296308 -> MCP handler U_y @253294456 returns {action:"decline"} to the server + ElicitationResult: "block", // iOt @259172485 -> N_o @253296535 / Zbo @253568095 force {action:"decline"}. Vetoes the response, does NOT stop the agent + PostToolUse: "observe", // OVERTURNED from "block". We emit hookSpecificOutput.additionalContext (policy-evaluator.ts:520-534) -> man's additionalContexts branch @256141810, never blockingError; and the tool already ran either way + PostToolUseFailure: "observe", // OVERTURNED. han @256143680 yields only cancelled / blocking_error / additional_context; consumer @256840799 just appends messages after the failed tool result. No stop channel exists + SessionStart: "observe", // @254090768 blockingError -> Pur() = hook_non_blocking_error; session proceeds. Only additionalContext has effect + Setup: "observe", // @254092164 -> same Pur() downgrade + SubagentStart: "observe", // @254689000 -> same Pur() downgrade; the subagent launches anyway + SessionEnd: "observe", // TFt @259174994 reads only !succeeded -> stderr passthrough; `blocked` never read + StopFailure: "observe", // zpt @~259180670 discards the EM() return entirely. Docs concur: "output and exit code are ignored" + PermissionDenied: "observe", // @256829448 reads ONLY hookSpecificOutput.retry; also fires only for classifier==="auto-mode" + Notification: "observe", // h9 @259174090 — return value not assigned + InstructionsLoaded: "observe", // nPt @259173437 — return value not assigned + CwdChanged: "observe", // vtn @259173188 -> zop @259172968 reads only watchPaths/systemMessage + FileChanged: "observe", // Atn @259173298 -> same zop consumer + PostCompact: "observe", // xpt @259170833 builds a display message; `blocked` never read (contrast MEe/PreCompact directly above it) + WorktreeRemove: "observe", // Zor @259183361 only records whether some hook succeeded; a deny just logs + // WorktreeCreate DELIBERATELY OMITTED: ODt @259182432 is a path PROVIDER, not + // a gate — with no stdout it throws "hook succeeded but returned no worktree + // path", so an ALLOW aborts creation exactly like a deny. Degenerate, not a + // capability. Fix the bug (emit a path on allow) before classifying it. + }, + + // ── codex ───────────────────────────────────────────────────────────────── + // openai/codex @fe01054a. Every verdict funnels through codex-rs/core/src/hook_runtime.rs. + codex: { + PreToolUse: "block", // tools/registry.rs:528 PreToolUseHookResult::Blocked -> FunctionCallError::RespondToModel; handler never runs. CAVEAT hook_runtime.rs:202 an empty reason silently degrades to Continue + PermissionRequest: "block", // tools/approvals.rs:220 Deny -> ReviewDecision::denied. CAVEAT only consulted when the call reaches the approval path (orchestrator.rs:168 Skip bypasses it) + UserPromptSubmit: "block", // events/user_prompt_submit.rs:226 Some(2)+stderr -> should_stop; turn.rs:588 skips record_pending_input, turn.rs:237 returns. CAVEAT turn.rs:603 — a co-submitted accepted message lets the turn proceed + Stop: "block", // events/stop.rs:303 Some(2) -> should_block + continuation_prompt; turn.rs:473-489 records it and `continue`s. CAVEAT turn.rs:490 a block with no prompt is warned + ignored + SubagentStop: "block", // shared parse_completed (stop.rs:202) + same consumer turn.rs:473. CAVEAT hook_runtime.rs:345 — only ThreadSpawn subagents dispatch it; all other SubAgent sources never run the hook + PostToolUse: "observe", // codex CAN block here (registry.rs:678) but we emit hookSpecificOutput.additionalContext with no top-level `decision`, and output_parser.rs:215 needs decision==="block". Inert by our choice; tool already ran regardless + SessionStart: "observe", // events/session_start.rs:243-322 has NO Some(2) arm — our exit-2 deny is logged as HookRunStatus::Failed. CLI blocks only on exit 0 + {"continue":false} (session_start.rs:273 -> turn.rs:233) + SubagentStart: "observe", // permanently unblockable: session_start.rs:273 guard excludes it, output schema has no `decision`, vendor doc says continue:false does not stop it + PreCompact: "observe", // compact.rs:246-260 no Some(2) arm; latent block needs {"continue":false} (-> core/src/compact.rs:190 TurnAborted). Inert as we emit + PostCompact: "observe", // compact.rs:349-363 same structure; latent block needs {"continue":false} (-> compact.rs:216). Inert as we emit + }, + + // ── copilot ─────────────────────────────────────────────────────────────── + // Closed source; vendor build ships readable JS. Offsets are BYTE offsets into + // the @github/copilot-linux-x64 app.js (c71/ = 1.0.71, copilot/ = 1.0.68). + // CRITICAL: exit 2 is NEVER a deny channel on copilot, for any event + // (runtime.node@60056587 "Hook command exited with code 2 (warning)"). + copilot: { + PreToolUse: "block", // 1.0.68 app.js@2878277 permissionDecision==="deny" -> resultType:"denied"; 1.0.71 moved to Rust, runtime.node@60059067 "Denied by preToolUse hook". Corroborated LIVE on 1.0.71 (session events success:false code:denied) + daily integration-suite probe + Stop: "block", // 1.0.71 app.js@2855430 agentStop: decision==="block" && string reason -> enqueueUserMessage(reason). We emit exactly that shape + SubagentStop: "block", // 1.0.71 app.js@1074101 subagentStop: decision==="block" && reason -> `continue` re-runs the subagent turn. CAVEAT skipped entirely for isSidekick subagents + UserPromptSubmit: "block", // FIXED: we now emit {decision:"block",reason} at exit 0 (gate V$t @2547438, consumer @2823018). Was inert — we sent exit 2 + stderr, which copilot logs as a warning for EVERY event and never treats as a deny + PermissionRequest: "block", // FIXED: we now emit the FLAT {behavior,message} copilot parses (normalizer CMn @179042 -> mapper h4t @2686538). Was inert — the Codex-shaped nested hookSpecificOutput.decision normalized to {} + PostToolUse: "observe", // OVERTURNED. Gate vK @173853 needs top-level decision==="block"; we emit hookSpecificOutput.additionalContext. Post-hoc anyway (result-rewrite only). Vendor docs: "Can block? No" + SessionStart: "observe", // 1.0.71 app.js@2836633 reads ONLY additionalContext + SessionEnd: "observe", // 1.0.71 app.js@2627263 `.then(()=>{})` — return explicitly thrown away + PostToolUseFailure: "observe", // 1.0.68 app.js@2883424 and @3038111 destructure only additionalContext + PreCompact: "observe", // 1.0.71 app.js@2758186 and @2852753 — result unassigned at both call sites + Notification: "observe", // 1.0.71 app.js@2746820 reads only additionalContext + // ErrorOccurred is installed but has no canonical HookEventType, so it + // cannot be keyed here. It is observe: 1.0.71 app.js@2846297, result unassigned. + }, + + // ── cursor ──────────────────────────────────────────────────────────────── + // Closed source; local install ships webpack-minified JS. Offsets are CHAR + // offsets into the shipped cursor-agent 2026.07.16-899851b bundle. + cursor: { + PreToolUse: "block", // 3143.index.js char 26967 permission==="deny" -> {type:"rejected"}; second MCP consumer char 42084 + Stop: "block", // 1931.index.js char 881820 followup_message queued as the next user message. CAPS: loop_limit default 5 (index.js char 4123300); consumed ONLY on the status:"completed" path — user-abort (char 870298) and turn-error (char 883549) discard it + UserPromptSubmit: "block", // FIXED: we now emit {continue:false,user_message} (1931.index.js char 887883 — the only block key). Was inert: {permission:"deny"} validates as an unknown-key object and is dropped on this event + PostToolUse: "observe", // 3143.index.js char 26339 fireSuccessAsync reads only additional_context; validator accepts nothing else (index.js char 4126700) + SessionStart: "observe", // 1931.index.js char 731566 consumes only env + additional_context; `continue` never read even though Cursor's own q() builds it + SessionEnd: "observe", // 1931.index.js char 724540 — result assigned to nothing + // SubagentStop OMITTED: producer exists (index.js char 3769676 forwards + // followupMessage) and vendor docs describe loop-style follow-ups, but no + // code in the shipped install ever CONSTRUCTS a subagentStop request, so + // the consumer is outside the bundle. unknown. + }, + + // ── opencode ────────────────────────────────────────────────────────────── + // sst/opencode @7565e035 (v1.18.9); every finding re-checked at tag v1.14.33. + opencode: { + PreToolUse: "block", // session/tools.ts:106-110 trigger runs BEFORE item.execute (:111) inside run.promise; a throw is an Effect defect that kills the fiber. Upstream test test/tool/code-mode.test.ts:428 proves the tool body never executed + PostToolUse: "observe", // session/tools.ts:121-125 sits after item.execute; a throw replaces the output with a tool-error the model sees, but the write/command already happened + SessionStart: "observe", // plugin/index.ts:255 `void hook["event"]?.(…)` — fire-and-forget, return structurally unreadable + SessionEnd: "observe", // plugin/index.ts:255 — same void dispatch + UserPromptSubmit: "observe", // plugin/index.ts:255 same void dispatch; and message.updated is published from session.ts:631 AFTER the message is persisted. The real gate is chat.message, which we do not install (§4) + PermissionRequest: "observe", // ☠ DEAD HOOK: permission.ask is DECLARED (packages/plugin/src/index.ts:261) and documented upstream, but NEVER INVOKED — the only two dispatch sites are hook["event"] (:255) and Plugin.trigger (:288), and permission.ask is not among the 13 names passed to .trigger(). The policy does not even run. See §5.3 for the copy hazard + // Stop OMITTED: session.idle's plugin return is definitively discarded + // (plugin/index.ts:255), so we instead call client.session.prompt(...) + // out-of-band (integrations.ts:886-897). That SDK route does start a fresh + // turn, but the composite (alive at idle? one-shot `opencode run`?) is + // unverified end to end. unknown. + }, + + // ── pi ──────────────────────────────────────────────────────────────────── + // @earendil-works/pi-coding-agent 0.80.10 installed (d.ts read directly); + // source cross-read at repo cced6a2 (0.82.1). Identical result shapes. + pi: { + PreToolUse: "block", // tool_call: ToolCallEventResult{block,reason} (types.d.ts:766) -> runner.emitToolCall returns on result.block -> agent-loop.ts:626 `if(beforeResult?.block) return createErrorToolResult(...)`; tool never executes. user_bash also maps here and is now FIXED too: the shim returns a full-replacement {result:BashResult} (types.d.ts:772) so the command is not run — {block,reason} had no matching field + UserPromptSubmit: "block", // FIXED: the shim now returns {action:"handled"} (InputEventResult, types.d.ts:629) so the prompt is never submitted. Was inert — {block,reason} matched no branch. CAVEAT: `handled` drops the prompt SILENTLY; Pi shows the user nothing, so the shim logs the reason to stderr + PostToolUse: "observe", // tool_result: ToolResultEventResult{content,details,isError,usage} (types.d.ts:778) — no block field; mutation only + SessionStart: "observe", // on(event:"session_start", ExtensionHandler) — NO Result type param (types.d.ts:842); runner.emit() only consumes returns for session_before_* (runner.ts:787-813) + SessionEnd: "observe", // session_shutdown: same — handler declared with no Result type (types.d.ts:848) + Stop: "observe", // agent_end: handler has NO Result type (types.d.ts:857) — the turn cannot be retried. Our shim stashes the reason and injects it into the NEXT turn via before_agent_start.systemPrompt (index.ts:458). Real, but it is a next-turn instruction, not a gate + }, + + // ── hermes ──────────────────────────────────────────────────────────────── + // hermes-agent @5771a6e. agent/shell_hooks.py _parse_response is EVENT-GATED: + // it returns a verdict only for pre_tool_call and pre_verify. + hermes: { + PreToolUse: "block", // pre_tool_call — the only tool gate; the tool does not run + PostToolUse: "observe", // post_tool_call — not in _parse_response's gate + SubagentStop: "observe", // OVERTURNED from CLAUDE.md's "✅ block". Not in _parse_response's gate, AND the call site (tools/delegate_tool.py) discards the return. Customers with a SubagentStop deny had ZERO enforcement + SessionStart: "observe", // on_session_start — not gated in + SessionEnd: "observe", // on_session_end — call site discards the return + // Stop: NOT KEYED — `pre_verify` is a real turn-end gate upstream but we do + // not install it, so no canonical Stop event ever fires for hermes. + }, + + // ── openclaw ────────────────────────────────────────────────────────────── + // openclaw/openclaw @f8ed8ecf (v2026.7.2) + our own shipped shim + // openclaw-plugin/index.js, which returns undefined for 5 of the 8 hooks. + openclaw: { + PreToolUse: "block", // before_tool_call -> shim returns {block:true, blockReason} + UserPromptSubmit: "block", // before_agent_run -> shim returns {outcome:"block", reason} + Stop: "block", // before_agent_finalize -> shim returns {action:"revise", reason}; the turn re-runs + PostToolUse: "observe", // void upstream AND our shim returns undefined + SessionStart: "observe", // void upstream AND our shim returns undefined + SessionEnd: "observe", // void upstream AND our shim returns undefined + SubagentStop: "observe", // subagent_ended cannot veto upstream; our shim also returns undefined + PreCompact: "observe", // before_compaction — same + }, + + // ── factory (droid) ─────────────────────────────────────────────────────── + // droid 0.175.1 (the shipped droid binary, ELF not stripped, embedded JS readable). + // Anchors below are unique grep-able strings in that bundle. + factory: { + PreToolUse: "block", // grep `throwing ToolExecutionControlError`: exit 2 -> throw $SH(stderr); also hookSpecificOutput.permissionDecision==="deny" -> throw $SH, continue===false -> throw LSH, exit 3 -> AgentAbortError. We emit exit 2 + stderr + UserPromptSubmit: "block", // executeUserPromptSubmitHooks: `if(B.exitCode===2||B.continue===!1){ … return {prompt,hookContext,blocked:!0} }` — the turn never starts + Stop: "block", // grep `[Agent] Stop hook blocking with decision: block`: {decision:"block",reason} sets the continue flag; grep `[Agent] Stop hook blocking with exit code 2` shows exit 2 is a FALLBACK too (CLAUDE.md says otherwise — harmless, we emit the JSON) + PreCompact: "block", // grep `[Compaction] PreCompact hook blocked compaction`: exit 2 or 3 -> returns the uncompacted conversation + PostToolUse: "observe", // exit 2 -> ADD_MESSAGE(role:"system", visibility:"llm_only") with the postToolUseFeedbackPrefix — feedback, not a block; tool already ran. (continue===false / exit 3 WOULD stop, but we emit neither) + SubagentStop: "observe", // `await MR("SubagentStop",{…})` — the awaited result is not assigned to anything. Hermes-class: our SubagentStop deny is discarded + SessionStart: "observe", // applySessionStartHookResults consumes only the env file + additionalContext; `if(L.exitCode!==0) TH("[Session] SessionStart hook failed")` just logs + SessionEnd: "observe", // executeSessionEndHooks: `for(let r of n) if(r.exitCode!==0) TH("[Session] SessionEnd hook failed")` — logs only + Notification: "observe", // `await MR("Notification",{…})` — return unassigned + }, + + // ── devin ───────────────────────────────────────────────────────────────── + // devin 3000.2.17 is a STRIPPED Rust ELF — no readable call sites. Tier is + // LIVE_PROBE: 11 recorded runs against the real binary with an isolated + // --config, each verified by side effect (marker file) AND by absence of the + // reason in ~/.local/share/devin/cli/{transcripts,sessions.db}. + devin: { + PreToolUse: "block", // probe L9: "Error: A tool was rejected by the user", RAN_MARKER absent — and it overrode --permission-mode dangerous + UserPromptSubmit: "block", // probe L10: "Prompt blocked: "; no PreToolUse/Stop hook fired; no model turn + Stop: "block", // probe L11 + transcript orchid-pillow.json /steps/8,/steps/10: the reason is injected as a USER message and the agent takes extra turns + PermissionRequest: "block", // probe L17: {"decision":"block"} -> marker absent, run aborted; L16: {"decision":"approve"} flipped a rejection into an execution, proving the channel is live. ⚠⚠ CONDITIONAL — see §5.1: never fires under --permission-mode dangerous (L8) and never for auto-approved read-only tools (L18, where a secret leaked) + PostToolUse: "observe", // probe L12: marker created, session finished normally, reason absent from transcript AND sessions.db + SessionStart: "observe", // probe L13: session ran normally, reason absent from transcript and DB + SessionEnd: "observe", // probe L14: session ended normally, reason absent + // Static corroboration (strings @82817071): "Ignoring pre_stop decision type:", + // "Ignoring permission decision type:", "Blocked by hook", "Denied by hook", + // "Prevented by hook" — agent-ext/src/hooks/event_handler.rs. + }, + + // ── antigravity (agy) ───────────────────────────────────────────────────── + // agy 1.1.8. Two independent kinds of evidence: live probes, plus the + // embedded FileDescriptorProto (binary @80315228, exa.hooks_pb) which is + // AUTHORITATIVE about which fields a response can even carry. + antigravity: { + PreToolUse: "block", // probe [1]: `touch PROBE_RAN` never ran; agent reported the tool "blocked with … PROBE: denied". Proto: PreToolHookResult{decision, reason, allow_tool, deny_reason} + Stop: "block", // probe [4]: {"decision":"continue",reason} re-entered the loop AND the agent obeyed the reason (STOP_GATE_PROOF created). Proto: StopHookResult{decision, reason} + PostToolUse: "observe", // probe [2]: agy logged `unknown field "decision"` (protojson unmarshal failure) 5x and continued. Proto: PostToolHookResult is an EMPTY MESSAGE — nothing is consumable + UserPromptSubmit: "observe", // PreInvocation. probe [3]: our {"decision":"deny"} caused `failed to call custom pre-invocation hook … unknown field "decision"`; 4 invocations ran unimpeded. Proto: PreInvocationHookResult carries ONLY inject_steps. (instruct works — probe [5] injectSteps honoured) + }, + + // ── goose ───────────────────────────────────────────────────────────────── + // block/goose, read AT TAG v1.43.0 (the installed version) as well as HEAD + // 0b234bdc. Two dispatchers: emit() is fire-and-forget (crates/goose/src/ + // hooks/mod.rs:295 — the return only feeds a warn! log); emit_blocking() + // (:364) consumes exit 2 or {"decision":"block","reason"} via deny_reason(:429). + goose: { + PreToolUse: "block", // v1.43.0 agent.rs:1080 emit_blocking -> HookDecision::Deny -> returns Err(ErrorData "Tool call denied by policy hook ``: …"); the tool is never dispatched + UserPromptSubmit: "observe", // v1.43.0 agent.rs:1609 and :1935 use emit() — verdict discarded + PostToolUse: "observe", // v1.43.0 agent.rs:~593 with_post_tool_hook uses emit() — verdict discarded, and it is post-hoc + SessionStart: "observe", // v1.43.0 agent.rs:1595 emit_hook -> emit() — verdict discarded + SessionEnd: "observe", // goose-cli/src/session/mod.rs:529 and :1294 emit_hook -> emit() — verdict discarded + // Goose HAS a blocking Stop event we do not install — see §4. CLAUDE.md and + // the policy-evaluator comment both claim "Goose has NO Stop event"; that is + // false at v1.43.0 (agent.rs:1956, :2840 emit_stop_hook_blocking). + }, +}; + +/** + * Capability for a (cli, event) pair, or `undefined` when not verified. + * + * Callers MUST branch on `undefined` and render nothing. Defaulting it to + * "block" would assert protection nobody confirmed; defaulting to "observe" + * would tell a user a working policy is inert. Silence is the only honest + * answer for a pair we have not traced. + */ +export function enforcementFor( + cli: IntegrationType | undefined, + event: HookEventType | string | undefined, +): EnforcementCapability | undefined { + if (!cli || !event) return undefined; + const perCli = ENFORCEMENT_CAPABILITY[cli]; + if (!perCli) return undefined; + return perCli[event as HookEventType]; +} diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 69a33fb1..642f645d 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -29,7 +29,7 @@ import type { PolicyFunction, PolicyResult } from "./policy-types"; import { readMergedHooksConfig } from "./hooks-config"; import { registerBuiltinPolicies } from "./builtin-policies"; import { evaluatePolicies } from "./policy-evaluator"; -import { clearPolicies, registerPolicy } from "./policy-registry"; +import { clearPolicies, registerPolicy, getPoliciesForEvent } from "./policy-registry"; import { loadAllCustomHooks } from "./custom-hooks-loader"; import type { CustomHook } from "./policy-types"; import { persistHookActivity } from "./hook-activity-store"; @@ -324,6 +324,16 @@ export async function handleHookEvent( process.stderr.write(result.stderr); } + // Which policies actually ran for this event, regardless of how they + // decided. `result.policyName` names only the decider — null on a plain + // allow — so without this a row cannot tell "your policy ran and allowed" + // from "no policy covers this event". The lookup is the same cached call + // the evaluator already made, so it costs nothing. + const matchedPolicies = getPoliciesForEvent( + canonicalEventType, + parsed.tool_name as string | undefined, + ).map((p) => p.name); + // Persist activity to disk (visible in /policies activity tab) const activityEntry = { timestamp: Date.now(), @@ -332,6 +342,7 @@ export async function handleHookEvent( toolName: (parsed.tool_name as string) ?? null, policyName: result.policyName, policyNames: result.policyNames, + matchedPolicies, decision: result.decision, reason: result.reason, durationMs, diff --git a/src/hooks/hook-activity-store.ts b/src/hooks/hook-activity-store.ts index a5e57862..26931736 100644 --- a/src/hooks/hook-activity-store.ts +++ b/src/hooks/hook-activity-store.ts @@ -47,6 +47,20 @@ export interface HookActivityEntry { toolName: string | null; policyName: string | null; policyNames?: string[]; + /** + * Every policy that MATCHED this event and ran, whichever way it decided. + * + * `policyName` only ever names the policy that *decided* — on a plain + * `allow()` it is null, so a row could not distinguish "no policy applies + * here" from "your policy ran and allowed". That is the whole reason a user + * cannot tell whether a policy fired, and why an observation-only event looks + * identical to an uncovered one. + * + * Optional because rows written before this existed have no such field; every + * consumer must tolerate `undefined` rather than render an empty list as + * "nothing ran". + */ + matchedPolicies?: string[]; decision: "allow" | "deny" | "instruct"; reason: string | null; durationMs: number; diff --git a/src/hooks/hooks-config.ts b/src/hooks/hooks-config.ts index c2fee069..27f07ddc 100644 --- a/src/hooks/hooks-config.ts +++ b/src/hooks/hooks-config.ts @@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { homedir } from "node:os"; -import type { HooksConfig } from "./policy-types"; +import type { HooksConfig, ConventionPolicyRecord } from "./policy-types"; import type { HookScope } from "./types"; import { hookLogInfo, hookLogWarn } from "./hook-logger"; @@ -171,6 +171,64 @@ export function writeScopedHooksConfig(config: HooksConfig, scope: HookScope, cw writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); } +/** + * Mirror the convention policies discovered at `scope` into that scope's + * config file, so `policies-config.json` shows what is installed rather than + * only what was explicitly enabled. + * + * Wholesale replace, not merge: the point is to reflect the directory as it is + * now, so a deleted file must vanish from the record. Writes only when the + * value actually changes, so repeated `failproofai policies` runs do not + * rewrite the file (and do not churn its mtime) for no reason. + * + * Callers must be one-shot commands. This is never safe to call from the hook + * path — see the `conventionPolicies` doc comment on `HooksConfig`. + * + * Returns true when the file was written. + */ +export function syncConventionPolicies( + entries: ConventionPolicyRecord[], + scope: HookScope, + cwd?: string, +): boolean { + const configPath = getConfigPathForScope(scope, cwd); + + // Never bring a config file into existence just to record an empty list — + // that would litter every directory the CLI is run from. + if (!existsSync(configPath) && entries.length === 0) return false; + + // NEVER write over a file we could not parse. `readScopedHooksConfig` fails + // soft, returning `{enabledPolicies: []}` on a syntax error — correct for the + // hook path, which must not die because a config is malformed, but fatal + // here: writing that default back silently DESTROYS the user's real settings. + // A hand-edited file with one stray comma would lose every enabled policy the + // moment someone ran `failproofai policies`, a read-only command. Bail and + // leave the file exactly as it is; the parse warning already surfaced. + if (existsSync(configPath)) { + try { + JSON.parse(readFileSync(configPath, "utf8")); + } catch { + hookLogWarn( + `not recording convention policies: ${configPath} is not valid JSON — ` + + `fix the syntax error and re-run. Nothing was written.`, + ); + return false; + } + } + + const config = readScopedHooksConfig(scope, cwd); + const sorted = [...entries].sort((a, b) => a.file.localeCompare(b.file)); + const next: HooksConfig = { ...config }; + if (sorted.length === 0) delete next.conventionPolicies; + else next.conventionPolicies = sorted; + + if (JSON.stringify(next.conventionPolicies ?? null) === JSON.stringify(config.conventionPolicies ?? null)) { + return false; + } + writeScopedHooksConfig(next, scope, cwd); + return true; +} + export interface ResolvedLlmConfig { baseUrl: string; apiKey: string; diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 011b93bf..4a403d93 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -12,8 +12,10 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import { parseDocument, type Document } from "yaml"; +import { listHermesProfiles, hermesRoot } from "../../lib/hermes-profiles"; import { HOOK_EVENT_TYPES, + CLAUDE_INSTALL_EVENT_TYPES, HOOK_SCOPES, CODEX_HOOK_EVENT_TYPES, CODEX_HOOK_SCOPES, @@ -118,6 +120,18 @@ export interface Integration { /** Resolve the per-scope settings/hooks file path. */ getSettingsPath(scope: HookScope, cwd?: string): string; + /** + * Every settings file this integration must write for a scope. + * + * Optional — defaults to `[getSettingsPath(scope, cwd)]`, which is right for + * every CLI whose scope maps to exactly one file. Hermes overrides it because + * each of its PROFILES is a separate home dir with its own `config.yaml`: + * writing only `~/.hermes/config.yaml` would leave every other profile running + * unhooked, silently. Use `settingsPathsFor(integration, …)` at call sites + * rather than calling this directly. + */ + getSettingsPaths?(scope: HookScope, cwd?: string): string[]; + /** Read the raw settings/hooks file (returns {} when missing). */ readSettings(settingsPath: string): Record; @@ -143,13 +157,28 @@ export interface Integration { detectInstalled(): boolean; } +/** + * Every settings file to write/remove for `(integration, scope)`. + * + * Single-file integrations (all but Hermes) return exactly one path, so callers + * can loop unconditionally. + */ +export function settingsPathsFor( + integration: Integration, + scope: HookScope, + cwd?: string, +): string[] { + const paths = integration.getSettingsPaths?.(scope, cwd); + return paths && paths.length > 0 ? paths : [integration.getSettingsPath(scope, cwd)]; +} + // ── Claude Code integration ───────────────────────────────────────────────── export const claudeCode: Integration = { id: "claude", displayName: "Claude Code", scopes: HOOK_SCOPES, - eventTypes: HOOK_EVENT_TYPES, + eventTypes: CLAUDE_INSTALL_EVENT_TYPES, getSettingsPath(scope, cwd) { const base = cwd ? resolve(cwd) : process.cwd(); @@ -196,7 +225,48 @@ export const claudeCode: Integration = { const s = settings as ClaudeSettings; if (!s.hooks) s.hooks = {}; - for (const eventType of HOOK_EVENT_TYPES) { + // Drop our hook from any event we no longer install. Without this, an + // event removed from the list stays in settings.json forever on existing + // machines — reinstalling would not repair it — which is exactly the + // situation `WorktreeCreate` created: registered, silent on allow, and + // therefore breaking `claude --worktree` until hand-edited. Only OUR + // marked entries are touched; anyone else's hooks on the same event stay. + // + // This walks EVERY key the file happens to carry, including ones + // failproofai has never written — a newer Claude event, another tool's + // entry, a typo. Their values are therefore unvalidated input, and a + // non-array one (`"Foo": {}` or `"Foo": "bar"`) makes the iteration below + // throw. That aborts the whole install AFTER the policies were recorded as + // enabled, so the user is left believing they are covered while no hook was + // written at all — the silent non-enforcement this file exists to prevent. + // Skip anything not shaped like a matcher list and leave it untouched. + const installed = new Set(CLAUDE_INSTALL_EVENT_TYPES); + for (const eventType of Object.keys(s.hooks)) { + if (installed.has(eventType)) continue; + const matchers = s.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + // Drop a matcher group only when WE emptied it. A group we never touched + // (no `hooks` array, or one that held nothing of ours) is somebody else's + // and is written back exactly as found — pruning is for our own entries, + // not a cleanup pass over the user's file. + const kept: ClaudeHookMatcher[] = []; + for (const matcher of matchers as ClaudeHookMatcher[]) { + if (!matcher || !Array.isArray(matcher.hooks)) { + if (matcher) kept.push(matcher); + continue; + } + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter( + (h) => !isMarkedHook(h as Record), + ); + const weEmptiedIt = matcher.hooks.length === 0 && before > 0; + if (!weEmptiedIt) kept.push(matcher); + } + s.hooks[eventType] = kept; + if (kept.length === 0) delete s.hooks[eventType]; + } + + for (const eventType of CLAUDE_INSTALL_EVENT_TYPES) { const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry; if (!s.hooks[eventType]) s.hooks[eventType] = []; const matchers: ClaudeHookMatcher[] = s.hooks[eventType]; @@ -1315,10 +1385,19 @@ export const hermes: Integration = { scopes: HERMES_HOOK_SCOPES, eventTypes: HERMES_HOOK_EVENT_TYPES, - // Hermes config is USER-scope only (`~/.hermes/config.yaml`); there is no - // project/local file, so scope/cwd are irrelevant here. + // Hermes config is USER-scope only (`/config.yaml`); there is no + // project/local file, so scope/cwd are irrelevant here. This is the DEFAULT + // profile's config — see getSettingsPaths for the rest. getSettingsPath() { - return resolve(homedir(), ".hermes", "config.yaml"); + return resolve(hermesRoot(), "config.yaml"); + }, + + // Every profile is a separate Hermes home with its own config.yaml, so a + // single-file install would leave non-default profiles running unhooked — + // silently, since Hermes never reports a missing hook. Install/uninstall + // therefore cover them all. + getSettingsPaths() { + return listHermesProfiles().map((p) => resolve(p.home, "config.yaml")); }, readSettings(settingsPath) { @@ -1403,21 +1482,13 @@ export const hermes: Integration = { return removed; }, + // "Installed" means EVERY profile is hooked, not just some. A profile created + // after install has no hooks, and reporting green there would hide exactly the + // silent-unenforcement gap this integration exists to close; reporting "not + // installed" instead prompts a re-install, which writes the missing profiles. hooksInstalledInSettings(scope, cwd) { - const settingsPath = this.getSettingsPath(scope, cwd); - if (!existsSync(settingsPath)) return false; - try { - const doc = readYamlDoc(settingsPath); - const js = (doc.toJS() ?? {}) as { hooks?: Record }; - const hooks = js.hooks; - if (!hooks || typeof hooks !== "object") return false; - for (const entries of Object.values(hooks)) { - if (Array.isArray(entries) && entries.some((h) => isMarkedHook(h))) return true; - } - } catch { - // Corrupt config — treat as not installed. - } - return false; + const paths = settingsPathsFor(this, scope, cwd); + return paths.length > 0 && paths.every((p) => hermesConfigHasHooks(p)); }, detectInstalled() { @@ -1425,6 +1496,39 @@ export const hermes: Integration = { }, }; +function hermesConfigHasHooks(settingsPath: string): boolean { + if (!existsSync(settingsPath)) return false; + try { + const doc = readYamlDoc(settingsPath); + const js = (doc.toJS() ?? {}) as { hooks?: Record }; + const hooks = js.hooks; + if (!hooks || typeof hooks !== "object") return false; + for (const entries of Object.values(hooks)) { + if (Array.isArray(entries) && entries.some((h) => isMarkedHook(h))) return true; + } + } catch { + // Corrupt config — treat as not installed. + } + return false; +} + +/** + * Profiles that exist but have no failproofai hooks — i.e. Hermes instances + * currently running unenforced. Empty when Hermes isn't installed at all. + * + * `listHermesProfiles()` always seeds a `default` entry so install has a path to + * write even on a fresh machine, so this filters to homes that actually exist — + * otherwise every machine WITHOUT Hermes would report one unhooked profile and + * the wizard would replace its accurate "not on PATH" hint with a phantom + * warning. + */ +export function unhookedHermesProfiles(): string[] { + return listHermesProfiles() + .filter((p) => existsSync(p.home)) + .filter((p) => !hermesConfigHasHooks(resolve(p.home, "config.yaml"))) + .map((p) => p.name); +} + // ── OpenClaw integration ──────────────────────────────────────────────────── // // OpenClaw is a self-hosted assistant gateway. Enforcement is via its in-process diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 3cc5d1e8..afeaf2a8 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -14,15 +14,16 @@ import { type HookScope, type IntegrationType, } from "./types"; -import { claudeCode, getIntegration } from "./integrations"; +import { claudeCode, getIntegration, settingsPathsFor } from "./integrations"; import { promptPolicySelection } from "./install-prompt"; -import { readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig } from "./hooks-config"; -import type { HooksConfig } from "./policy-types"; +import { readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig, syncConventionPolicies, findProjectConfigDir } from "./hooks-config"; +import type { HooksConfig, ConventionPolicyRecord } from "./policy-types"; import { BUILTIN_POLICIES } from "./builtin-policies"; import { loadCustomHooks, discoverPolicyFiles } from "./custom-hooks-loader"; import { trackHookEvent } from "./hook-telemetry"; import { getInstanceId, hashToId } from "../../lib/telemetry-id"; import { CliError } from "../cli-error"; +import { hookLogWarn } from "./hook-logger"; const VALID_POLICY_NAMES = new Set(BUILTIN_POLICIES.map((p) => p.name)); @@ -261,12 +262,16 @@ async function installHooksImpl( const writtenSettingsPaths: { cli: IntegrationType; path: string }[] = []; for (const cliId of selectedClis) { const integration = getIntegration(cliId); - const settingsPath = integration.getSettingsPath(scope, cwd); + // Usually one path; Hermes returns one per profile (each is a separate home + // dir with its own config.yaml, and a missed one runs unhooked in silence). + const settingsPaths = settingsPathsFor(integration, scope, cwd); try { - const settings = integration.readSettings(settingsPath); - integration.writeHookEntries(settings, binaryPath, scope); - integration.writeSettings(settingsPath, settings); - writtenSettingsPaths.push({ cli: cliId, path: settingsPath }); + for (const settingsPath of settingsPaths) { + const settings = integration.readSettings(settingsPath); + integration.writeHookEntries(settings, binaryPath, scope); + integration.writeSettings(settingsPath, settings); + writtenSettingsPaths.push({ cli: cliId, path: settingsPath }); + } } catch (err) { const errorType = err instanceof Error && /EACCES|EPERM/.test(err.message) ? "permission_denied" @@ -452,9 +457,11 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al : []; for (const s of scopesToRemove) { - const settingsPath = integration.getSettingsPath(s, cwd); + // Usually one path; Hermes returns one per profile. + const settingsPaths = settingsPathsFor(integration, s, cwd); + const existing = settingsPaths.filter((p) => existsSync(p)); - if (!existsSync(settingsPath)) { + if (existing.length === 0) { if (scope !== "all" && selectedClis.length === 1) { console.log("No settings file found. Nothing to remove."); nothingToReport = true; @@ -462,18 +469,22 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al continue; } - const removed = integration.removeHooksFromFile(settingsPath); - if (removed === 0 && scope !== "all" && selectedClis.length === 1) { + let removedHere = 0; + for (const settingsPath of existing) { + const removed = integration.removeHooksFromFile(settingsPath); + removedHere += removed; + if (removed > 0 && scope !== "all") { + console.log(`Removed ${removed} failproofai hook(s) from ${integration.displayName} settings.`); + console.log(`Settings: ${settingsPath}`); + } + } + + if (removedHere === 0 && scope !== "all" && selectedClis.length === 1) { console.log("No hooks found in settings. Nothing to remove."); nothingToReport = true; continue; } - totalRemoved += removed; - - if (scope !== "all") { - console.log(`Removed ${removed} failproofai hook(s) from ${integration.displayName} settings.`); - console.log(`Settings: ${settingsPath}`); - } + totalRemoved += removedHere; } } @@ -484,7 +495,9 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al for (const cliId of selectedClis) { const integration = getIntegration(cliId); for (const s of integration.scopes) { - console.log(` ${integration.displayName} / ${s}: ${integration.getSettingsPath(s, cwd)}`); + for (const p of settingsPathsFor(integration, s, cwd)) { + console.log(` ${integration.displayName} / ${s}: ${p}`); + } } } } @@ -696,33 +709,86 @@ export async function listHooks(cwd?: string): Promise { } // Convention Policies section (.failproofai/policies/*policies.{js,mjs,ts}) - const base = cwd ? resolve(cwd) : process.cwd(); + // Walk up to the project root like enforcement does + // (custom-hooks-loader -> findProjectConfigDir); resolving at the exact cwd + // meant running this from a subdirectory listed no project policies while the + // hook path was loading them. + const base = findProjectConfigDir(cwd ?? process.cwd()); + const projectDir = resolve(base, ".failproofai", "policies"); + const userDir = resolve(homedir(), ".failproofai", "policies"); + const sameDir = userDir === projectDir; const conventionDirs: { label: string; dir: string }[] = [ - { label: "Project", dir: resolve(base, ".failproofai", "policies") }, - { label: "User", dir: resolve(homedir(), ".failproofai", "policies") }, + { label: sameDir ? "Project + User" : "Project", dir: projectDir }, + // Running from $HOME makes both paths identical. Listing the directory + // twice printed every file a second time as "failed to load" — the file was + // already imported by the first pass, so the module cache short-circuits + // `customPolicies.add` and `loadCustomHooks` legitimately returns 0 hooks. + // Nothing was wrong with the policy; the second listing was. + ...(sameDir ? [] : [{ label: "User", dir: userDir }]), ]; + // Record of what was found, mirrored into policies-config.json below so the + // config shows installed convention policies and not only enabled builtins. + const discovered: Record<"project" | "user", ConventionPolicyRecord[]> = { + project: [], + user: [], + }; + for (const { label, dir } of conventionDirs) { const files = discoverPolicyFiles(dir); if (files.length === 0) continue; + // A shared project/user directory is listed once but belongs to both, so + // its record is written to both scopes' config files. + const targets: ("project" | "user")[] = + dir === projectDir && dir === userDir ? ["project", "user"] : dir === projectDir ? ["project"] : ["user"]; + const record = (file: string, hooks: string[]) => { + for (const t of targets) discovered[t].push({ file, hooks }); + }; + console.log(`\n \u2500\u2500 Convention Policies \u2014 ${label} (${dir}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`); + // `nameColWidth` is sized to the longest BUILTIN name, but a convention + // filename can be longer \u2014 `padEnd` is then a no-op and the hook count runs + // straight into the filename with no space + // (`enforce-bengaluru-event-links-policies.mjs1 hook(s)`). Widen to fit the + // files actually being printed, keeping a two-space gutter. + const colWidth = Math.max(nameColWidth, ...files.map((f) => basename(f).length + 2)); for (const file of files) { try { const hooks = await loadCustomHooks(file); + const filename = basename(file); + record(filename, hooks.map((h) => h.name)); if (hooks.length === 0) { - const filename = basename(file); - console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(nameColWidth)}\x1B[31mfailed to load\x1B[0m`); + console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(colWidth)}\x1B[31mfailed to load\x1B[0m`); } else { - const filename = basename(file); const hookSummary = hooks.map((h) => h.name).join(", "); - console.log(` \x1B[32m\u2713\x1B[0m ${filename.padEnd(nameColWidth)}${hooks.length} hook(s): ${hookSummary}`); + console.log(` \x1B[32m\u2713\x1B[0m ${filename.padEnd(colWidth)}${hooks.length} hook(s): ${hookSummary}`); } } catch { const filename = basename(file); - console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(nameColWidth)}\x1B[31merror\x1B[0m`); + record(filename, []); + console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(colWidth)}\x1B[31merror\x1B[0m`); } } console.log(); } + + // Mirror what was just listed into the USER config. Safe here because + // `failproofai policies` is a one-shot command — never do this on the hook + // path (see the HooksConfig.conventionPolicies doc comment). + // + // User scope only, deliberately. A project's `.failproofai/policies-config.json` + // is routinely committed (this repo tracks its own), so writing the record + // there would make a plain `failproofai policies` dirty the working tree and + // put a spurious diff in front of every contributor — a read command must not + // do that. `discovered.project` is still collected so the listing above can + // report it; it simply is not persisted. + try { + syncConventionPolicies(discovered.user, "user"); + } catch (err) { + // Listing must never fail because the mirror could not be written. + hookLogWarn( + `could not record convention policies in config: ${err instanceof Error ? err.message : String(err)}`, + ); + } } diff --git a/src/hooks/policy-evaluator.ts b/src/hooks/policy-evaluator.ts index 6e6a511b..ce20e195 100644 --- a/src/hooks/policy-evaluator.ts +++ b/src/hooks/policy-evaluator.ts @@ -179,6 +179,21 @@ export async function evaluatePolicies( decision: "deny", }; } + // `beforeSubmitPrompt` does NOT read `permission`. Its only block key + // is `continue: false` (+ an optional `user_message` shown to the + // user); an object carrying unknown keys validates and is dropped, so + // the flat deny below was inert and every prompt went through. Verified + // against cursor-agent 2026.07.16-899851b, 1931.index.js char 887883. + if (eventType === "UserPromptSubmit") { + return { + exitCode: 0, + stdout: JSON.stringify({ continue: false, user_message: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } const response = { permission: "deny", user_message: blockedMessage, @@ -229,13 +244,15 @@ export async function evaluatePolicies( } // Hermes: the block contract is `{"decision":"block","reason"}` on stdout; - // Hermes IGNORES exit codes, so the JSON is the only channel. This one - // return covers every Hermes event we install (PreToolUse / PostToolUse / - // SubagentStop) — there is no Stop/turn-end event to special-case (Hermes - // has no turn boundary; the 5 require-*-before-stop builtins never fire for - // it — see CLAUDE.md / the audit plan). A block on PreToolUse stops the - // tool before it runs, regardless of the originating platform - // (slack/telegram/cli/cron) or subagent. + // Hermes IGNORES exit codes, so the JSON is the only channel. A block on + // PreToolUse stops the tool before it runs, regardless of the originating + // platform (slack/telegram/cli/cron) or subagent. + // + // Only `pre_tool_call` and `pre_verify` are gated in upstream's + // `_parse_response` (agent/shell_hooks.py:567-621); the shape is still + // emitted for the other installed events, where it is read by nothing. + // We do not install `pre_verify` (see the note in types.ts), so Hermes + // has no canonical Stop event and no Stop branch is needed here. if (session?.cli === "hermes") { return { exitCode: 0, @@ -422,6 +439,41 @@ export async function evaluatePolicies( }; } + // Copilot reads two shapes we were not sending, so a deny on either of + // these events was emitted, logged, counted as enforcement — and ignored. + // Both verified against the shipped @github/copilot-linux-x64 bundle. + // Note exit 2 is NEVER a deny channel on copilot for any event; it is + // logged as `Hook command exited with code 2 (warning)`. + if (session?.cli === "copilot") { + // `userPromptSubmit` gates the turn, but only on {decision:"block"} at + // exit 0 (consumer app.js@2823018). We were emitting exit 2 + stderr, + // which copilot warns about and then submits the prompt anyway. + if (eventType === "UserPromptSubmit") { + return { + exitCode: 0, + stdout: JSON.stringify({ decision: "block", reason: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + // Copilot consumes a FLAT {behavior, message} here (normalizer + // CMn@179042 -> mapper h4t@2686538). The Codex-shaped nested + // hookSpecificOutput.decision below normalizes to `{}` on copilot, so + // the permission prompt proceeded as if no policy existed. + if (eventType === "PermissionRequest") { + return { + exitCode: 0, + stdout: JSON.stringify({ behavior: "deny", message: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + } + if (eventType === "PermissionRequest") { // Codex-only: hookSpecificOutput.decision.behavior = "allow" | "deny" // (per https://developers.openai.com/codex/hooks#permissionrequest). diff --git a/src/hooks/policy-types.ts b/src/hooks/policy-types.ts index 47692c98..344a1e8e 100644 --- a/src/hooks/policy-types.ts +++ b/src/hooks/policy-types.ts @@ -93,4 +93,30 @@ export interface HooksConfig { * Only ever written explicitly as `false`. */ customPoliciesEnabled?: boolean; + /** + * Record of the convention policies (`.failproofai/policies/*policies.mjs`) + * discovered at this scope the last time a failproofai command ran. + * + * DESCRIPTIVE, NEVER AUTHORITATIVE. Enforcement always discovers from the + * filesystem (`loadAllCustomHooks`) and never reads this key — a policy file + * dropped in enforces on the very next tool call whether or not this list has + * caught up. Making the loader trust it would mean a freshly-copied policy + * silently doing nothing until some command refreshed the config, which is + * the exact silent-non-enforcement failure this project exists to remove. + * + * Refreshed wholesale (not merged) by `failproofai policies` and the setup + * wizard, so a deleted file disappears from the record on the next run. It is + * deliberately NOT written from the hook path: that runs on every tool call, + * and a read-modify-write from concurrent short-lived hook processes with no + * locking would corrupt the file that governs enforcement. + */ + conventionPolicies?: ConventionPolicyRecord[]; +} + +/** One convention policy file and the hooks it registered, as last observed. */ +export interface ConventionPolicyRecord { + /** Basename, e.g. `hermes-guardrails.policies.mjs`. */ + file: string; + /** Hook names the file registered, or `[]` if it failed to load. */ + hooks: string[]; } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 9a851469..b1021195 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -105,8 +105,34 @@ export const HERMES_TOOL_INPUT_MAP: Record> = { // (`~/.hermes/config.yaml`; Hermes has no project scope). `pre_tool_call` is the // core deny point — it fires for tool calls from every source // (slack/telegram/cli/cron) and internal subagents, so a single install -// intercepts all platforms. Hermes has NO turn-end `Stop` event, so the -// `require-*-before-stop` builtins never fire for it (see the audit plan). +// intercepts all platforms. +// +// `pre_verify` IS a turn-end gate — the earlier claim here that Hermes has none +// was wrong. We deliberately do NOT install it (product decision, 2026-07-29); +// this note records what it would buy so the choice can be revisited without +// re-deriving it. +// +// Upstream fires it once per turn when the agent has edited code and is about to +// finish (`agent/conversation_loop.py:6754`), and its parser accepts our Claude +// Stop shape verbatim — `{decision:"block", reason}` means "block the stop", +// i.e. keep going (`agent/shell_hooks.py:606-615`). The reason is injected as a +// synthetic user message and the loop re-enters +// (`conversation_loop.py:6774-6800`). Installing it is one entry in +// config.yaml — it is a shell hook like the other five, no Python involved. +// +// It would make the 5 `require-*-before-stop` builtins fire on Hermes, subject +// to three upstream conditions: +// 1. It fires ONLY on turns that landed a file mutation, and that means +// exactly `write_file` or `patch` (`agent/tool_result_classification.py:9`) +// — a turn that did its work through `terminal` (sed -i, rm, >) does not +// qualify, so a chat-only gateway may never see it. +// 2. Capped at 3 nudges per turn (`DEFAULT_MAX_VERIFY_NUDGES`, +// `agent/verify_hooks.py:21`), operator-overridable; resets each turn. +// 3. It landed upstream ~2026-06-30. Older Hermes fails the key against +// VALID_HOOKS and warn-and-skips it SILENTLY (`agent/shell_hooks.py:325`). +// +// Until it is installed, `HERMES_EVENT_MAP` emits no `Stop` and those 5 +// builtins remain inapplicable on Hermes. export const HERMES_HOOK_SCOPES = ["user"] as const; export type HermesHookScope = (typeof HERMES_HOOK_SCOPES)[number]; @@ -1002,8 +1028,30 @@ export const HOOK_EVENT_TYPES = [ "Setup", ] as const; + + export type HookEventType = (typeof HOOK_EVENT_TYPES)[number]; +/** + * Events failproofai actually INSTALLS a hook for on Claude Code. + * + * Everything in `HOOK_EVENT_TYPES` except `WorktreeCreate`, which is not a + * permission gate at all: Claude uses it as a worktree-PATH PROVIDER, taking + * the stdout of the first hook that succeeds as the directory to create and + * failing with "WorktreeCreate hook failed" when no hook supplies one. Our + * allow path writes nothing to stdout — correctly, by the contract every other + * event uses — so merely being registered there broke `claude --worktree` and + * `/worktree` for every user, whatever any policy decided. + * + * It stays in `HOOK_EVENT_TYPES` because that list is the canonical set a + * policy may subscribe to; this one governs what we write into settings.json. + * No builtin matches it (all 39 match only PreToolUse / PostToolUse / + * PermissionRequest / Stop), so nothing is lost by not registering. + */ +export const CLAUDE_INSTALL_EVENT_TYPES = HOOK_EVENT_TYPES.filter( + (e) => e !== "WorktreeCreate", +) as readonly HookEventType[]; + export const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__" as const; export interface ClaudeHookEntry {