diff --git a/.grok/hooks/failproofai.json b/.grok/hooks/failproofai.json new file mode 100644 index 000000000..113c1dcd9 --- /dev/null +++ b/.grok/hooks/failproofai.json @@ -0,0 +1,172 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUseFailure --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionDenied --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook StopFailure --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook Notification --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SubagentStart --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook SubagentStop --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreCompact --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostCompact --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli grok", + "timeout": 30, + "__failproofai_hook__": true + } + ] + } + ] + } +} diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 000000000..566a16638 --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,251 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionStart --cli qwen", + "name": "failproofai-SessionStart", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptSubmit --cli qwen", + "name": "failproofai-UserPromptSubmit", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreToolUse --cli qwen", + "name": "failproofai-PreToolUse", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUse --cli qwen", + "name": "failproofai-PostToolUse", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostToolUseFailure --cli qwen", + "name": "failproofai-PostToolUseFailure", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionRequest --cli qwen", + "name": "failproofai-PermissionRequest", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PermissionDenied --cli qwen", + "name": "failproofai-PermissionDenied", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook Stop --cli qwen", + "name": "failproofai-Stop", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook StopFailure --cli qwen", + "name": "failproofai-StopFailure", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SubagentStart --cli qwen", + "name": "failproofai-SubagentStart", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 1; }; node scripts/dev-hook.mjs --hook SubagentStop --cli qwen", + "name": "failproofai-SubagentStop", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PreCompact --cli qwen", + "name": "failproofai-PreCompact", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook PostCompact --cli qwen", + "name": "failproofai-PostCompact", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook Notification --cli qwen", + "name": "failproofai-Notification", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "InstructionsLoaded": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook InstructionsLoaded --cli qwen", + "name": "failproofai-InstructionsLoaded", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "UserPromptExpansion": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook UserPromptExpansion --cli qwen", + "name": "failproofai-UserPromptExpansion", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "TodoCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook TodoCreated --cli qwen", + "name": "failproofai-TodoCreated", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "TodoCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook TodoCompleted --cli qwen", + "name": "failproofai-TodoCompleted", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node scripts/dev-hook.mjs --hook SessionEnd --cli qwen", + "name": "failproofai-SessionEnd", + "timeout": 30000, + "__failproofai_hook__": true + } + ] + } + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db82aeea..e9171befb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.4-beta.1 — 2026-09-01 + +### Features + +- Add **Cline** as the 16th integration — live-hook enforcement plus an audit adapter, user and project scope, verified live against cline v3.0.60. Cline is the only integration whose config is a **directory of event-named files**: the install drops one launcher script per event, named exactly for the event it subscribes to. Three rules govern it and **all three fail silently when broken** — the FILENAME is the event (ten names, case-insensitive; a typo is skipped without a log line), the EXTENSION must be in cline's allowlist (a `hooks.json` is invisible twice over), and the EXIT CODE IS IGNORED (the verdict is the single JSON object on stdout, so the launcher always prints one, and prints `{}` on failure — unparseable stdout makes cline run the tool). `--hooks-dir` is a DEAD FLAG in v3.0.60: it writes `CLINE_HOOKS_DIR`, which appears exactly once in the shipped binary — that write — and is read by nothing. **Every cline tool is BATCH-shaped, and that is the whole integration**: `run_commands{commands[]}`, `read_files{files[{path}]}`, `search_codebase{queries[]}` and one multi-file `apply_patch{input}` blob, against builtins that read the scalars `tool_input.command` / `.file_path` / `.pattern`. A key rename would leave every one of them reading `undefined` and **allowing silently** — the inert-hook failure this repo has already shipped twice. So there is deliberately no `CLINE_TOOL_INPUT_MAP`; `batch-expand.ts` expands one call into N canonical scalar inputs and `batch-fanout.ts` runs the policy set once per element, lowest offending index winning and short-circuiting the rest. Joining the array instead was designed, judged and **rejected on evidence**: `SECRET_FILE_RE` is `/\.(?:pem|key)$/`, so under any join only the LAST element can match and a `.pem` at `files[0]` rides straight through — `cline-batch-bypass.test.ts` asserts the fan-out catches `sudo` at `commands[1]` and `.env` at `files[1]` with the REAL builtins, and asserts the join would have missed them, so a later "simplification" fails loudly instead of going quietly green. Two collapse details on the paths that cannot fan out are derived rather than chosen: commands join with `" &&\n"` (a bare `"\n"` silently disables `READ_LIKE_CMDS`, whose boundary alternation contains no newline; a bare `" && "` manufactures false denies across a boundary) and paths collapse via `pickRiskiestPath`, not `paths[0]`. Deny is `{"cancel":true,"errorMessage":…}`; cline's schema has no `decision`/`block`/`permissionDecision` field at all. Two caveats are recorded as product decisions rather than footnotes: `cancel:true` **aborts the whole run** (it becomes `{stop:true}` → `ControlledStopError`, observed live as `[abort] aborted by another client`) rather than denying one call, and cline is **fail-open with no opt-out** — a timeout, parse failure or spawn error runs the tool. `Stop` therefore emits `{"context":…}` instead of a cancel, since `TaskComplete` fires after the task has finished, and the 5 `require-*-before-stop` builtins are inapplicable as on Hermes, Goose and ori. Audit is the thinnest adapter we have because cline already stores **Claude's own content blocks** — `~/.cline/data/sessions/_/` with a metadata sidecar carrying a real `cwd` and a `messages.json` of `thinking`/`text`/`tool_use`/`tool_result`; the parser pairs results by id and deliberately does not emit a user turn for the `role:"user"` message that merely carries them. Uninstall deletes marked FILES and never the directory, because cline's hooks directory is shared with the user's own hooks (#PR) + +- Add **Ori** (OpenRouter's `ori`) as the 15th integration — live-hook enforcement plus an audit adapter, user scope. Everything below was verified live against ori 0.12.0+68f9a36 driving `nvidia/nemotron-3.5-lightning:free`, because almost none of it could be read off ori's shipped type declarations. **`ori` is two products behind one binary and only the second one needed anything built.** As a *launcher* (`ori claude`, `ori codex`, `ori grok`, `ori opencode`, …) it runs the real third-party binary with credentials injected and nothing else: `ori claude` passes `--settings ''` carrying only `apiKeyHelper` + `env`, `ori codex` passes `-c model_provider=…` overrides against `config.toml` (a different file from `hooks.json`), `ori grok` and `ori opencode` set env vars only, and none of them redirect `HOME` or a config dir. Both injections **merge** rather than replace — proven, not assumed: a project `SessionStart` hook fired identically with and without ori's exact `--settings` blob, and against `opencode debug config` the `plugin` array survived `OPENCODE_CONFIG_CONTENT` intact with `openrouter` added beside the existing provider. So the hooks already installed for those CLIs keep enforcing under `ori ` and this release deliberately ships nothing for that path. What it does ship covers ori's **own** agent (bare `ori` / `ori code`, the built-in `@ori-runloop/agent-loop`). Enforcement is not a hook-event stream but ori's published extension points, supplied by a feature directory failproofai generates at `~/.ori/global/features/failproofai/` — auto-discovered with no config file to register it in, like Goose's dropped plugin dir. User scope only, because bare `ori` boots the **global** workspace rather than the project's, so one install covers every project. **The mode caveat is the whole story for coverage and is documented rather than papered over**: ori's approval mode defaults to `self-drive`, and in that mode the dynamic approval points are never called, so policies never run. That was isolated three ways — no callback under self-drive; still none after claiming `approval-policy` with `defaultAction:"ask"`; but `defaultAction:"reject"` *did* block every call, which proves the static point is wired and that self-drive skips specifically the dynamic asker. There is no config key or env var to change the default (searched `config.json`, `ori.md` frontmatter, the `ORI_*` surface and the shipped selfdev docs), so `--approvals manual` is a documented requirement rather than a silent gap, and we claim `approval-policy` with an inert `ask` instead of a blanket `reject` precisely so installing failproofai cannot brick a self-drive session. In exchange ori is the **only integration that fails closed for free**: both dynamic points declare `failureBehavior: "deny"` — "a throwing, rejecting, or malformed provider denies the request" — so a failproofai fault blocks the call rather than waving it through, where Goose and OpenClaw fail open. Two payload properties are load-bearing and neither is in any doc: the gate fires **twice** per tool call (`escalated:false`, then `escalated:true` with a synthetic `escalated` argument), so the shim drops that pseudo-argument and policies must be idempotent; and `arguments` is a flat name/value **string** array, so everything arrives stringified. `ori`'s `edit` tool is the sharp edge — it carries the whole change as one `patch` blob in OpenAI apply_patch format with **no path argument at all**, so `file_path` was simply absent and `block-env-files`, `block-secrets-write` and every other path builtin would have silently no-opped on every edit; `oriPatchFilePaths()` recovers it from the `*** Update File:` header, with the multi-file limitation asserted in a test rather than left to be discovered. A deny reaches the model as a bare tool failure because ori's verdict shape has no reason field, so `instruct()` degrades to allow + a stderr note, and there is no `Stop` event at all, leaving the 5 `require-*-before-stop` builtins inapplicable as on Hermes and Goose. Audit reads the transcript out of `~/.ori/global/.ori/state.sqlite` — `ori_agent_loop_history.prompt` is misnamed and holds the entire serialized conversation, after three plausible stores turned out to be dead ends (the session dir's `metadata.json` is a summary with no messages even for a successful tool-using run, `code-*.jsonl` is lifecycle logging with zero tool records, and the rich event stream exists only transiently on `--output jsonl` stdout) (#PR) + +- Document **grok CLI** and **Qwen Code** in the harnesses reference, and point all three `--cli` validators at one array. Neither gap was visible on the branch that added them: `docs/reference/harnesses.mdx` and the `copy-counts` test that guards it both landed on `main` *after* that branch was cut, so grok and qwen were absent from the scopes, config-path, enforcement, conditions and probed-version tables — and the test only notices once the branch is rebased. `bin/failproofai.mjs` had three hand-written `VALID_CLIS` sets; two were already reading a shared `INSTALLABLE_CLIS` array and the third was still a literal, which is the shape that made `--install --cli grok` fail in the first place. All three now read the array, and `__tests__/hooks/cli-flag-coverage.test.ts` fails if a fourth literal appears or if the array drifts from `INTEGRATION_TYPES` — including for the `--hook` guard, whose fallback to `"claude"` is silent rather than loud (#PR) + ## 1.0.3 — 2026-08-31 One user-facing fix, and the three suite fixes that were needed to see it. @@ -430,6 +440,19 @@ disappearing quietly. - Open-source the Cloud CLI as `fp-cloud-cli` (command `fp`), moved out of the private AgentEye monorepo into `fp-cloud-cli/`. The distribution and the command differ because `fp` was taken on PyPI, and neither is the `failproofai` CLI this repo already builds — that one enforces inside the agent loop, this one reads back what the loop did. It is a hard cut, matching the collector binary's rename: no `agenteye` alias, no retired env-var fallback, no config migration, so scripts calling `agenteye ...` break on upgrade and users run `fp login` once. Env vars move to `FP_*` and the config to `~/.fp/cli.json`. The `X-AgentEye-Org` / `X-AgentEye-Client` headers and the `ae_session` cookie are deliberately unchanged — they are a contract with the dashboard and the Rust server, and renaming them from one side would misroute tenants with a 200 rather than an error. Brings the first Python into the repo: a matrixed `fp-cloud-cli` CI job, a Trusted-Publishing PyPI workflow, a `uv` dependabot ecosystem and the lockfile in the osv-scanner gate. Also fixes a README that documented a command renamed long ago and a default it claimed did not exist, a test fixture that ran the suite with TLS verification disabled, and a repo-root probe that would have resolved to the wrong repo here. (#702) - Announce every stable release in Discord, from the notes on the GitHub Release. `publish.yml` gains an `announce` job that runs LAST — after the registry check and all four `verify-install` legs, because a channel told to install something that 404s is worse than a channel told nothing — and posts one embed to the webhook in `DISCORD_RELEASE_WEBHOOK`, pinging the role in `DISCORD_RELEASE_ROLE_ID`. **The GitHub Release body is the source of record and `CHANGELOG.md` the fallback**: stable releases are cut from the Releases page, and the notes written there are what the maintainer decided this release says — announcing from the changelog instead would publish a DIFFERENT summary than the one on the release page, in the channel where more people read it. The fallback covers an empty body and a `workflow_dispatch`, which has no release event at all; read from the changelog, a stable version also collects its whole `-beta.*` line, since somebody moving 1.0.0 → 1.0.1 on `latest` receives all of it and the stable section deliberately does not restate it. Entries collapse to their first sentence — the headline every entry in this file already opens with — and both `(#123)` and GitHub's `by @someone in ` become the same `#123` link. Three things are load-bearing and none are obvious: the mention goes in `content` because **Discord does not resolve mentions inside an embed** and would render `<@&id>` as raw text pinging nobody; `allowed_mentions: {parse: [], roles: [id]}` is what stops an `@everyone` in somebody's release notes reaching the whole server; and the description is fitted by dropping WHOLE groups rather than truncating, because the first version cut the trailing `[Full changelog]` link off a 1.0.0-sized release and ended on `Stop sending anything about a…` — a notification showing a third of a release and pointing nowhere. Stable only, and both halves of that are required: a prerelease version is a beta nobody asked to be pinged about, and a stable version at a dist-tag other than `latest` would carry an install line resolving to something else. Preflight refuses a stable release with notes in neither source, which is the one point in the pipeline where failing costs nothing. Nothing depends on the `announce` job, so a dead webhook can never hold back a published package. (#721) +- Add **grok CLI** (xAI) as the 13th integration — live-hook enforcement plus an audit adapter, user and project scope. Verified live against grok 1.0.3: `{decision:"deny"}` blocks a tool call and overrides `--yolo`, and `{decision:"block"}` on `Stop` forces another turn. grok pipes a camelCase envelope, so `normalizeCliPayload` gains a `grok` branch; `GROK_TOOL_INPUT_MAP` maps `read_file`'s `target_file` and `list_dir`'s `target_directory` onto the keys builtins read, without which a live `.env` read walks straight past `block-env-files`. Two undocumented project-scope conditions are recorded in CLAUDE.md: grok discovers project hooks only inside a **git repo**, and only in a **trusted** folder. Audit reads `~/.grok/sessions///chat_history.jsonl` (#PR) + +- Add **Qwen Code** as the 14th integration — live-hook enforcement plus an audit adapter, user and project scope. Verified live against qwen-code 0.21.12: it honours Claude's own `permissionDecision` deny on `PreToolUse` (beating `-y`) and a top-level `{decision:"block"}` on `Stop`. Its payload is pure Claude snake_case and all six of its tools already deliver canonical input keys, so it needs no event map, no payload normalization and no tool-input map — only a name map. Audit reads `~/.qwen/projects//chats/.jsonl`, whose message bodies are Gemini-shaped `parts[]` rather than Claude content blocks (#PR) + +- **Subscribe grok and qwen to their full useful event surface** — grok 8 → **14 events** (its entire surface), qwen 12 → **19**. Verified before implementing rather than after: a live grok accepted all 14 config keys (`hook_count=14`, and grok silently skips names it does not recognize, so acceptance was the thing to check), and every qwen event has a real `executeHooks()` dispatch site in the shipped bundle. Reading that bundle also turned up three events qwen dispatches but does not document — `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch`. Most of the additions are observation, and on grok they can only ever be observation: its ACP handshake advertises `blockingEvents: ["pre_tool_use","stop","subagent_stop"]` and that is the complete list. The exception is qwen's `TodoCreated`/`TodoCompleted`, which a new `QWEN_EVENT_MAP` canonicalizes onto `TaskCreated`/`TaskCompleted` — these run in a `validation` phase where a `{decision:"block"}` genuinely prevents the write, verified live by blocking a todo that planned to skip the tests. Three events were deliberately left out: `MessageDisplay` fires per streaming chunk, `PostToolBatch` measured +76% hook invocations for a task while duplicating `PostToolUse`, and `SessionDelete` has no canonical equivalent (#PR) + +- Add grok and Qwen Code to the README's supported-CLI grid, with their marks taken from the same MIT-licensed icon set the existing twelve came from. grok's is monochrome and `currentColor` does not inherit inside an ``, so it ships as a light/dark pair rather than rendering black-on-black for half of readers; Qwen's colour mark reads on either theme and ships as one file. The grid moved from 6 columns to 7, since 14 CLIs is exactly two full rows there where 6 would leave a two-cell orphan row (#PR) + +- Fix every grok project 404ing from the projects list. grok percent-encodes its cwd folder (`%2Ftmp%2Ffp-prod`), and using that as the project name made it the URL slug — which re-encodes to `%252F…` in the link, and `/project/[name]` cannot resolve it. The slug is now the dash-encoded cwd, which is both URL-safe and byte-identical to the one Claude, Factory and Qwen derive for the same directory, so a project driven by grok and qwen now merges into one row instead of appearing twice (once unreachable). Found by clicking the link rather than by reading the list (#PR) + +- **Surface grok and qwen everywhere the other CLIs already appear.** Four gaps, all reported from a live dashboard: the two badges were indistinguishable, neither showed in the CLI filter, neither contributed to the projects list, and the daemon collected nothing from either. `lib/projects.ts` and the project detail page now aggregate both (their sessions were on disk and invisible); the badges moved off the status palette per the dashboard design system — status hues carry health meaning and must never be spent on identity — to neutral zinc for grok and magenta for qwen, which are also maximally far apart. The filter dropdown and badge colours were never missing from the code: they derive from `KNOWN_CLI_IDS`/`CLI_ENTRIES`, and the shipped `.next` bundle simply predated them, because the tarball had been packed with `--ignore-scripts` (#PR) + +- **Collect grok and qwen transcripts in the daemon, so their sessions reach the cloud like every other CLI's.** Two new `fpai-collect` sources plus their `main.rs` tasks and `HARNESS_KEYS` entries on both sides. qwen follows the Factory model (one JSONL per session, real timestamps) but its bodies are Gemini-shaped `parts[]` with `functionCall`/`functionResponse`, so the transform is its own. grok follows the **cursor** model instead, because its transcript carries **no timestamps at all**: events are stamped from the file mtime plus byte offset, keeping time approximately right while staying a pure function of the inputs, as the content-hash dedup requires. grok also needed its own path rules — the session id is the parent directory (every transcript is named `chat_history.jsonl`), the cwd folder is percent-encoded rather than dash-encoded, and `tool_calls[].arguments` arrives as a JSON string that is parsed so tool inputs stay queryable. Only `user` lines carrying `prompt_index` count as operator prompts; grok writes its own preamble and reminder injections as `user` lines too (#PR) ### Fixes @@ -491,6 +514,10 @@ disappearing quietly. - Give the nightly translation a voice when it fails, and a pulse when it does not run. It posted nothing by design — the reasoning being that its output is the pull request — which held for both success shapes and failed for the third: a run that dies also leaves no PR, so failing and idling produced the identical signal, none. Between 2026-08-11 and 2026-08-17 it opened nothing while 28 pages sat missing from 14 locales, and what noticed was a finding in the weekly docs audit rather than the job itself. Failure now posts to the same Slack webhook the other two jobs use, naming the step and carrying the log tail; success stays quiet, because a nightly "all good" is noise. Every exit also writes `last-run.json` into the work dir, and the weekly docs audit reports its AGE — the one failure no error handler can catch is the job never starting, and only a file's age can see that from outside. (#705) +- **Stop failproofai's Claude hooks from running inert inside grok.** grok's hook discovery scans `~/.claude/settings.json`, `~/.claude/settings.local.json` and `/.claude/settings.json` by default — the last being exactly the file `policies --install --cli claude --scope project` writes — so on any machine with both tools, grok was already executing our hooks: passing `--cli claude` while piping its own camelCase payload. `tool_name` and `tool_input` arrived `undefined`, so every builtin that matches a tool name or inspects a command or path (`block-sudo`, `block-env-files`, `block-secrets-write`, `block-force-push`) saw nothing and allowed. A deny would not have landed anyway, because grok ignores Claude's `hookSpecificOutput` shape — verified by A/B on one live hook: Claude's shape let the command run, grok's blocked it. The hooks were installed, running, costing latency, and enforcing nothing, which is worse than no coverage because the install reports success. The handler now detects grok's envelope from the payload shape alone (never from an env var, so a real Claude event cannot be misread) and routes the event onto grok's contract — tool maps and response shape both. Real Claude payloads are untouched (#PR) + +### Fixes + - **Stop the dashboard server's telemetry from stranding its own events, and stop it printing `Error while flushing PostHog` while doing it.** Four options on the `posthog-node` client each disabled a different part of the library's delivery machinery, and together they turned a slow network into lost events plus a stack trace in the user's terminal — the one `failproofai audit` starts, where `launch()`'s log filter only strips the Server Action skew block. The injected `resilientFetch` was the root of it: it retried five times over ~40s and then returned a synthetic `200` so the library would never log a network error, but posthog-node does not merely hand its abort signal to an injected fetch, it **races that fetch against its own `requestTimeout`** (`Promise.race([fetchPromise, deadline])`) precisely because an injected fetch may ignore the signal — which ours did, by stripping it. A ~40s budget racing a 5s deadline can never return in time, so the synthetic `200` was unreachable code, the `console.error` it existed to prevent fired anyway at 5s, and the retries ran on detached from a client that had already given up. Worse, that `200` was the wrong answer even when it did land: posthog-node deliberately does NOT dequeue a batch that failed with a network error, so reporting success is what would have made it discard events that never arrived. The wrapper is gone; plain global fetch is what the library expects. `fetchRetryCount` was `0`, leaving that wrapper as the only thing retrying, at the wrong layer — the library retries inside a single flush, knows which errors are retryable, and keeps its queue coherent while doing it. `requestTimeout` was `5000`, half the library's own default, so every attempt had half the room. And `flushInterval` was `0`, which is falsy and therefore disables the flush timer outright — that is the one that actually stranded events, because the batch posthog-node retains after a network error then had nothing scheduled to resend it and sat in an in-memory queue (`PostHogMemoryStorage`, so nothing survives the process) until some unrelated later event happened to trigger a flush. `flushAt: 1` is unchanged and deliberate: volume is a handful of events per process, batching buys nothing, and sending immediately is the best defense a memory-only queue has against the process dying. Measured against a server that answers correctly but takes 6s — a slow network, not an outage — the old options delivered the event **four times** and logged two flush errors, because the wrapper re-POSTed the same batch on each of its own retries while the library still held its retained copy; the new ones deliver it **once**, with nothing logged. The exit drain is now idempotent, since `beforeExit` re-fires every time a handler schedules async work and an unguarded one started a fresh 30s `shutdown()` on each pass. **No event, trigger or property changed** — all 73 call sites across the three dispatchers fire exactly as before. (#701) - Cover telemetry delivery against the real `posthog-node` and a real socket, in `__tests__/lib/telemetry-delivery.test.ts`. The existing suite mocks the library wholesale, and that mock is what let the above live in the tree: the constructor was called with the right *shape*, so it passed while events were being stranded. The new tests assert on bytes that arrived over a socket — including gunzipping the batch body, without which a green test means nothing, since posthog-node gzips it and a raw read silently parses as "no events delivered". They pin that a captured event reaches `/batch/` with its properties intact, that a transient 500 is retried and still delivered, that a successful flush logs no error, and — for the hook dispatcher carrying the other 37 call sites — that `trackHookEvent` reaches `/capture/` and that `flushHookTelemetry` lands events the caller never awaited. Two facts they nail down rather than fix: posthog-node **overwrites `$lib`** with its own name on the server path, so `trackEvent`'s `"failproofai"` never lands and `product` is the attribution that actually survives (the raw-fetch hook dispatcher has no SDK to overwrite it, so its `"failproofai-hooks"` does); and the opt-out still sends nothing. (#701) diff --git a/CLAUDE.md b/CLAUDE.md index a5b2b4b4f..0d4fc4a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -819,10 +819,468 @@ For production users the recommended Goose install is: failproofai policies --install --cli goose --scope project ``` +### grok hooks (`~/.grok/hooks/failproofai.json` / `.grok/hooks/failproofai.json`) + +xAI's **grok** CLI is the 13th integration — **dual-pillar** (live hooks + audit), +**user + project** scope, Claude/Codex-style external shell hooks. The whole contract +below was **verified live against grok 1.0.3 (1a29d5bc12)** with a recorder hook on all +14 events plus deny and stop-gate probes. Binary probe: `grok`. + +**Schema is Claude's nested form**, in its own directory — grok loads every `*.json` +under `~/.grok/hooks/` (user, always trusted) or `/.grok/hooks/` (project). We own +one file, `failproofai.json`, so install never merges with the user's other hook files +(same arrangement as Copilot's `.github/hooks/failproofai.json`). `timeout` is in +**seconds** (grok's default 5; 600 for Stop/SubagentStop). The matcher is **omitted** — +grok treats it as a regex, and an omitted matcher matches every tool. + +**`grok` READS OTHER CLIS' HOOK CONFIGS — this is the most important fact here.** +Discovery scans, by default (`[compat.claude] hooks = true`): + +``` +global : ~/.grok/hooks/ ~/.claude/settings.json ~/.claude/settings.local.json ~/.cursor/hooks.json +project: /.claude/settings.json /.claude/settings.local.json /.grok/hooks/ /.cursor/hooks.json +``` + +`/.claude/settings.json` is exactly what `policies --install --cli claude --scope +project` writes, so **grok executes our Claude hooks**, passing `--cli claude` while +piping its own camelCase payload. That made every such hook **inert**: `tool_name` and +`tool_input` arrived `undefined`, so every content/path builtin allowed — and a deny +would not have landed anyway, since grok ignores Claude's `hookSpecificOutput` shape +(A/B verified on one live hook). `resolveEffectiveCli()` in `normalize-cli-payload.ts` +detects grok's envelope **from the payload shape only** (`hookEventName` + +`workspaceRoot`, no `hook_event_name` — a shape Claude never sends; never an env var, +which could misread a real Claude event) and re-routes onto grok's contract. Note the +mirror case is **not** true: a Cursor-schema `~/.cursor/hooks.json` loads `count=0` — +grok accepts Cursor's event *names* but expects the nested Claude *structure*. + +Consequence worth knowing: in a repo carrying **both** `.claude/settings.json` and +`.grok/hooks/failproofai.json` (this repo does), a grok session fires **both**, so every +tool call is evaluated twice. Harmless — identical verdicts, deny wins — but it doubles +hook latency. + +**Two undocumented project-scope conditions, each a silent no-op:** + +| Condition | Symptom | +|---|---| +| The directory must be a **git repo** | A *trusted* non-git dir with a valid `.grok/hooks/*.json` logs `project_sources=0`; the hook never fires | +| The folder must be **trusted** | `grok --trust` or `/hooks-trust`; until then project hooks are silently skipped | + +Neither is in grok's hooks doc. Verified by A/B: the only change between +`project_sources=0` and `project_sources=4` was `git init`. User scope +(`~/.grok/hooks/`) needs neither. + +**Wire format — camelCase envelope, snake_case event *value*.** `hookEventName` +(`"pre_tool_use"`), `sessionId`, `cwd`, `workspaceRoot`, `transcriptPath`, +`permissionMode`, `toolName`, `toolInput`, `toolUseId`. `normalizeCliPayload`'s `grok` +branch maps these to snake_case, including `toolResult` → `tool_response` (grok does +**not** use Claude's key) and `workspaceRoot` → `cwd`. `hookEventName` is deliberately +**not** mapped — its value is snake_case while the canonical set is PascalCase, and the +`--hook` arg already carries the right name, so there is **no `GROK_EVENT_MAP`**. + +**Tool surface** (`GROK_TOOL_MAP`, every entry observed on the wire): +`run_terminal_command→Bash`, `write→Write`, `read_file→Read`, `search_replace→Edit`, +`grep→Grep`, `list_dir→LS`. grok's own docs disagree on the shell tool — +the hooks doc says `run_terminal_command` (what the wire sends), the headless doc says +`run_terminal_cmd`; both are mapped. **`GROK_TOOL_INPUT_MAP` has exactly two entries**: +`read_file` delivers the path as **`target_file`** and `list_dir` as +**`target_directory`**. The first is load-bearing — without it a live `.env` read passes +`block-env-files`, the identical bug `COPILOT_TOOL_INPUT_MAP` was added to fix. + +**Event surface: all 14, which is grok's entire surface.** `GROK_HOOK_EVENT_TYPES` +covers `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PostToolUseFailure`, `PermissionDenied`, `Stop`, `StopFailure`, `Notification`, +`SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact`, `SessionEnd`. A live grok +accepted every key (`hooks: loaded from global source … count=14`, `loaded hooks +hook_count=14`, no unknown-key warning) — worth checking rather than assuming, because +**grok silently SKIPS event keys it does not recognize**, so a typo costs coverage with no +error anywhere. `Notification` (type `agent_error` — a type grok's own docs omit) and +`StopFailure` (`{error:"rate_limit", errorDetails, lastAssistantMessage}`) were both +observed firing. Everything beyond `PreToolUse`/`Stop`/`SubagentStop` is **observation by +construction, not by caution** — the ACP handshake above is the complete blocking list. + +**Response shapes** (`policy-evaluator.ts`, `cli === "grok"`): + +| Case | Shape (exit 0) | +|------|----------------| +| Deny on tool/prompt events | `{decision:"deny", reason}` — VERIFIED, beat `--yolo` | +| Deny on `Stop`/`SubagentStop` | `{decision:"block", reason: }` — VERIFIED force-retry | +| Instruct on `Stop` | the same block shape (grok also documents `hookSpecificOutput.additionalContext` there; unverified, so we use the proven one) | +| Instruct on other events | stderr note only (degrade like Hermes/Goose) | + +**`Stop` fires TWICE per session** and only the first is actionable: once per real turn +end (`reason: "end_turn"`) and once at shutdown (`reason: "shutdown"`), whose decision +grok parses and then **discards**. The Stop branch gates on `reason === "end_turn"`; +blocking on the shutdown fire would log and count a deny that can never be acted on. An +unlabelled Stop is treated as real, so the failure mode leans toward enforcement. +Captured sequence for one turn: `end_turn`/`stopHookActive=false` (we blocked) → the +agent ran the required command → `end_turn`/`stopHookActive=true` (allowed) → +`shutdown`. grok caps continuations at **8 per turn**. + +Env injected on every hook: `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, +`GROK_WORKSPACE_ROOT`, and **`CLAUDE_PROJECT_DIR`** (a Claude-compatible alias). + +**Audit pillar.** `~/.grok/sessions///` — **percent** +encoding (`%2Fhome%2Fyou%2Frepo`), NOT the dash style Claude/Factory/Qwen use, so +`lib/grok-sessions.ts` decodes with `decodeURIComponent` and grok project folders do not +merge with Claude's by slug (cwd filtering still works). Each session dir holds +`chat_history.jsonl` (the turns), `events.jsonl`, and `summary.json` (`info.cwd`, +`session_summary`, `created_at`, `num_messages`). Two parser consequences: +`chat_history.jsonl` carries **no timestamps**, so `grokLinesToLogEntries` takes an +explicit `startMs` (summary.json's `created_at`) and lays turns 1ms apart — ordering +exact, per-turn wall-clock honestly synthesized; and `tool_calls[].arguments` is a JSON +**string**, parsed on the way in. Only `user` lines carrying `prompt_index` are real +operator turns (grok also writes its environment preamble and `synthetic_reason` +reminders as `user` lines). `GROK_HOME` overrides the home dir for tests. + +For production users the recommended grok install is: +```bash +failproofai policies --install --cli grok --scope project +``` + +### Qwen Code hooks (`~/.qwen/settings.json` / `.qwen/settings.json`) + +**Qwen Code** (`qwen`, Alibaba) is the 14th integration — **dual-pillar**, **user + +project** scope, and the **cheapest integration in the codebase**: it is a near-pure +Claude clone on the wire, so it needs **no event map, no payload normalization, and no +tool-input map**. Verified live against **@qwen-code/qwen-code 0.21.12**. + +Hooks live under a Claude-style `"hooks"` key inside qwen's normal settings file, which +also holds `model`, `modelProviders` and auth — so reads/writes go through the +merge-preserving `readJsonFile`/`writeJsonFile` helpers, never a whole-file replace. + +| Scope | Path | +|---------|-----------------------------| +| user | `~/.qwen/settings.json` | +| project | `/.qwen/settings.json` | + +**`timeout` is in MILLISECONDS** (qwen's default 60000) — the only integration that is +not seconds-based. Do not unify it with the others: `30` would mean 30ms and every hook +would time out. `disableAllHooks: true` and `--safe-mode` each disable every hook. + +**Wire format is pure Claude snake_case**: `hook_event_name` (PascalCase *value*, unlike +grok), `session_id`, `transcript_path`, `cwd`, `permission_mode`, `tool_name`, +`tool_input`, `tool_response`, `stop_hook_active`. + +**Tool surface** (`QWEN_TOOL_MAP`): `run_shell_command→Bash`, `read_file→Read`, +`write_file→Write`, `edit→Edit`, `grep_search→Grep`, `list_directory→LS` (plus qwen's +legacy `ReadFile`/`WriteFile` matcher aliases). There is deliberately **no +`QWEN_TOOL_INPUT_MAP`** — all six deliver canonical keys already. + +**Response shapes.** PreToolUse honours Claude's own +`hookSpecificOutput.permissionDecision` (`allow`/`deny`/`ask`), VERIFIED live beating +`-y`, so it falls through to the generic Claude branch rather than being duplicated; +`"ask"` degrades to deny in headless runs and background subagents. Only **`Stop`** +diverges — it reads the top-level `{decision:"block", reason}`, VERIFIED forcing another +turn — which is all the `cli === "qwen"` branch in `policy-evaluator.ts` handles. +**Instruct is a real channel** here (`hookSpecificOutput.additionalContext` on +PreToolUse/PostToolUse/UserPromptSubmit, wrapped in a +`` provenance tag), putting qwen ahead of +Hermes/Goose/Factory, which all degrade instruct to a stderr note. + +**Event surface: 19 subscribed.** Every event failproofai installs has a real +`executeHooks("")` dispatch site in the shipped bundle — checked by reading it, +which is also how `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch` turned +up: all three are dispatched and **absent from qwen's documented event table**. +`QWEN_EVENT_MAP` exists for exactly two of the nineteen: qwen calls its task list "todos", +so `TodoCreated`/`TodoCompleted` canonicalize onto `TaskCreated`/`TaskCompleted`. + +**qwen's todo hooks are a real veto point, not observation.** They run in two phases and +the payload says which: during `phase: "validation"` a top-level `{decision:"block", +reason}` prevents the write and the reason goes back to the model; during `postWrite` the +todo is already persisted and the block is ignored. `policy-evaluator.ts` emits that shape +unconditionally for `TaskCreated`/`TaskCompleted` — it enforces where it can and is inert +where it cannot, and the phase is upstream's to decide. Verified live: a policy denying a +todo that planned to skip the tests blocked the `todo_write` outright. Note the block +prevents the **whole write**, not the single offending item. + +**Three events are deliberately NOT subscribed:** `MessageDisplay` fires per streaming +chunk (a hook process per chunk), `PostToolBatch` fired 6× in a task where `PostToolUse` +fired 5 while carrying the same tool calls in batch form (+76% hook invocations, no +builtin reads it), and `SessionDelete` has no canonical equivalent. Each is one line to +add if a custom policy ever needs it. + +**Two behaviours that will bite a policy author:** + +- **`stop_hook_active` is `true` on the FIRST Stop fire**, before anything has blocked + (verified live). It is NOT a usable "already retrying" signal on qwen, and no + failproofai loop guard may depend on it. Unlike grok there is no session-end Stop fire. +- **`UserPromptSubmit` fires per MODEL INVOCATION, not per user prompt** — one observed + turn produced **four**, one per tool-result continuation. qwen's docs confirm it covers + UserQuery/ToolResult/Hook sends and warn that `prompt` is not necessarily user input. + `submitted_prompt` is present only for interactive-TUI submissions (absent in headless, + ACP, `serve`, SDK), so it identifies real submissions but cannot be a general filter. + +**Audit pillar.** `~/.qwen/projects//chats/.jsonl` — +Claude-style encoded-cwd folders, but note the extra `chats/` level. Every line carries +`{uuid, parentUuid, sessionId, cwd, timestamp, type}`, and a real per-line `cwd` means +audit groups by project like Claude/Devin/Goose. The message body is **Gemini-shaped, +not Claude-shaped**: `message.parts[]` of `{text}` / `{functionCall:{id,name,args}}` / +`{functionResponse:{id,name,response}}`, with the assistant role spelled `"model"` — so +`lib/qwen-sessions.ts` is NOT a clone of `factory-sessions.ts` despite the similar +layout. A `toolCallResult` sidecar carries the rendered `resultDisplay`, preferred over +the raw response blob. `QWEN_HOME` overrides the home dir for tests. + +**Not failproofai's bug, but worth knowing:** qwen exports the provider API key from its +settings `env` block into **every hook process's environment**, so any hook a user +installs can read it. + +For production users the recommended Qwen install is: +```bash +failproofai policies --install --cli qwen --scope project +``` + +### Cline hooks (`~/.cline/hooks/` / `.clinerules/hooks/`) + +Cline is a **dual-pillar** integration (live hooks + audit), **user + project** scope. It is +the only integration whose config is a **directory of event-named files** rather than a +settings file. The whole contract below was **verified live against cline v3.0.60**. + +**Three rules, and all three fail SILENTLY when broken:** + +1. **The FILENAME is the event**, case-insensitively. Only ten names exist: `TaskStart`, + `TaskResume`, `TaskCancel`, `TaskComplete`, `TaskError`, `PreToolUse`, `PostToolUse`, + `UserPromptSubmit`, `PreCompact`, `SessionShutdown`. A typo is not an error — cline + skips the file without a log line. This is exactly how an early probe concluded, wrongly, + that cline had no reachable hook surface: right directory, wrong names. +2. **The EXTENSION must be in the allowlist** — `"" .sh .bash .zsh .js .mjs .cjs .ts .mts + .cts .py .ps1` — or the file is skipped, also silently. A `hooks.json` is invisible twice + over. The file need **not** be executable; the interpreter comes from the shebang, else + the extension. +3. **The EXIT CODE IS IGNORED** on every event. The verdict is the single JSON object on + stdout, so the generated launcher must always print exactly one object and print `{}` + when failproofai produced nothing — unparseable stdout makes cline skip the hook and run + the tool. + +Hook directories, in cline's own search order: + +| Path | Scope | +|------|-------| +| `~/Documents/Cline/Hooks` | (read, not written) | +| `$CLINE_DIR/hooks` (default `~/.cline/hooks`) | **user** | +| `/.clinerules/hooks` | **project** | +| `/.cline/hooks` | (read, not written) | + +**`--hooks-dir` IS A DEAD FLAG** in v3.0.60: it writes `CLINE_HOOKS_DIR`, which appears +exactly once in the shipped binary — that write — and is read by nothing. Never emit it. + +**Deny contract:** `{"cancel": true, "errorMessage": "…"}` on stdout at exit 0. Cline's +verdict schema is `{contextModification?, cancel?, review?, errorMessage?, context?, +overrideInput?}` — there is **no** `decision` / `block` / `permissionDecision` field, so +every generic branch in `policy-evaluator.ts` is inert for it and the `cli === "cline"` +branch must come first. `context` is a real additional-context channel (so `instruct()` does +**not** degrade to stderr-only here, unlike Goose/Hermes/ori), and `overrideInput` is a real +MUTATE channel — not yet wired, but it is the thing that would make the `sanitize-*` builtins +actually sanitize. + +**Two caveats that are product decisions, not footnotes:** + +- **`cancel:true` ABORTS THE WHOLE RUN.** It becomes `{stop:true}` → `applyStopControl` + throws `ControlledStopError`. Observed live as `[abort] aborted by another client`. It is a + *stronger* action than a per-tool deny, not a weaker one — every other integration denies + one call and lets the agent adapt. +- **FAIL-OPEN, with no way to opt out.** A timeout (120s default), a parse failure or a spawn + error makes cline skip the hook and run the tool. Unlike ori there is no + `failureBehavior:"deny"` to inherit, so a failproofai fault on cline is a silent allow. + +`Stop`/`StopFailure` deliberately do **not** emit a cancel: `TaskComplete` fires after the +task has finished, so a cancel would kill a completed run rather than force a retry. We emit +`{"context": …}` there instead, and `ENFORCEMENT_CAPABILITY` records `Stop: "observe"`. The 5 +`require-*-before-stop` builtins are **inapplicable** on cline, as on Hermes, Goose and ori. +`PreCompact` is one of cline's ten names but is excluded from `CLINE_HOOK_EVENT_TYPES`: it +maps to `undefined` upstream and is skipped at dispatch. + +**EVERY CLINE TOOL IS BATCH-SHAPED, and this is the whole integration.** Captured live: + +| cline tool | input | canonical | +|------------|-------|-----------| +| `run_commands` | `{commands: ["cd '' && ls -la", …]}` | `Bash` | +| `read_files` | `{files: [{path, start_line, …}, …]}` | `Read` | +| `search_codebase` | `{queries: ["alpha", …]}` | `Grep` | +| `apply_patch` | `{input: "*** Begin Patch\n*** Update File:

…"}` | `Edit` | + +failproofai's builtins read SCALARS (`tool_input.command`, `.file_path`, `.pattern`), so a +key rename leaves every one of them reading `undefined` and **allowing silently** — the +inert-hook failure this repo has shipped twice. There is therefore **no +`CLINE_TOOL_INPUT_MAP`**; do not "fix" the omission by adding an empty one. Instead +`src/hooks/batch-expand.ts` expands one call into N canonical scalar inputs and +`src/hooks/batch-fanout.ts` runs the policy set once per element, **lowest offending index +wins**, short-circuiting the rest. + +**Why not just join the array?** Because `SECRET_FILE_RE` is `/\.(?:pem|key)$/`. Under any +join only the LAST element can match, so a `.pem` at `files[0]` rides straight through. +`__tests__/hooks/cline-batch-bypass.test.ts` asserts that with the real builtins, and also +asserts the join *would* have missed it — so a later "simplification" fails loudly instead of +going quietly green while enforcement disappears. + +A collapse still happens on the paths that cannot fan out (PostToolUse, audit replay, +fail-closed shaping): `canonicalizeClineToolInput` derives the best single scalar and keeps +the arrays under `cline_*`. Two details there are derived, not chosen — commands join with +`" &&\n"` (a bare `"\n"` silently disables `READ_LIKE_CMDS`, whose boundary alternation has +no newline; a bare `" && "` manufactures false denies across a boundary), and paths collapse +via `pickRiskiestPath`, not `paths[0]`. + +`apply_patch` carries the **same OpenAI apply_patch format as ori's `edit`**, so +`splitApplyPatch` in `batch-expand.ts` serves both and `oriPatchFilePaths` is now an alias +for it. It also returns per-file old/new text, which closes ori's documented multi-file +KNOWN GAP — and a worse one the gap comment understates, that ori's Edit sets no +`old_string`/`new_string` at all, so `block-secrets-write` can never fire on it. Deliberately +NOT changed for ori in this PR; it is a five-line follow-up now rather than a re-derivation. + +**Payload normalization** (`normalize-cli-payload.ts`): cline pipes its own payload verbatim +— its hooks are event-named scripts, not a generated shim that could pre-shape stdin — so +without a branch every field the handler reads is `undefined`. Read `tool_call.input`, **not** +`preToolUse.parameters`: the latter JSON-**stringifies** every array value, so reading it +would hand the batch expander a string and silently drop the whole fan-out. `taskId` → +`session_id`; `workspaceRoots[0]` (else `workspaceInfo.rootPath`) → `cwd`. + +**Audit pillar** — the thinnest adapter we have, because cline already stores **Claude's own +content blocks**. Per-session directories at `~/.cline/data/sessions/_/` +hold `.json` (metadata: `session_id`, `source`, `status`, `provider`, `model`, **`cwd`**, +`workspace_root`, `prompt`, `started_at`/`ended_at`) and `.messages.json` +(`{version, updated_at, agent, sessionId, origin, system_prompt, messages}`) whose blocks are +`{type:"thinking"|"text"|"tool_use"|"tool_result"}`. `lib/cline-sessions.ts` pairs +`tool_result` onto `tool_use` by id and — importantly — does **not** emit a user turn for the +`role:"user"` message that merely carries results, or every tool call produces a phantom turn. +Cline stores **no per-message timestamp**, so every entry carries the session's own time. +`CLINE_HOME` / `CLINE_DIR` override the home for tests. + +**Uninstall must delete FILES, never the directory.** Unlike ori's feature directory, cline's +hooks directory is shared with the user's own hooks; `removeHooksFromFile` removes only files +carrying the failproofai marker, and `writeHookEntries` refuses to clobber a file that lacks +it. + +For production users the recommended Cline install is: +```bash +failproofai policies --install --cli cline --scope project +``` + +### Dogfood configs for Factory / Devin / Antigravity / Goose / grok / Qwen +### Ori hooks (`~/.ori/global/features/failproofai/`) + +Like Hermes and OpenClaw, this repo ships **no dogfood ori config** — ori is user-scope +only, so an install here would rewire the contributor's whole machine rather than just +this repo. Ori is a **dual-pillar** integration (live hooks + audit). Everything below was +**verified live against ori 0.12.0+68f9a36** driving `nvidia/nemotron-3.5-lightning:free`; +almost none of it is derivable from ori's shipped `.d.ts`. + +**`ori` is two products behind one binary, and only the second one is this integration.** + +1. **A launcher** — `ori claude`, `ori codex`, `ori grok`, `ori opencode`, `ori hermes`, + `ori omp`, `ori prime-agent`, `ori kilo`, `ori dsh` — which runs the real third-party + binary under OpenRouter credentials. It injects credentials and **nothing else**, and + critically it does **not** redirect `HOME` or any config dir: + + | Launcher | What ori injects | + |----------|------------------| + | `ori claude` | `--settings ''` carrying only `apiKeyHelper` + `env` | + | `ori codex` | `-c model_provider=…` key overrides (against `config.toml`, a *different file* from `hooks.json`) | + | `ori grok` | env only (`GROK_MODELS_BASE_URL`, `GROK_XAI_API_BASE_URL`, `XAI_API_KEY`, telemetry off) | + | `ori opencode` | `OPENCODE_CONFIG_CONTENT` inline JSON | + + Both injections **merge** rather than replace, and that was proven rather than assumed: + a project `SessionStart` hook fired identically with and without ori's exact + `--settings` blob, and against `opencode debug config` the `plugin` array survived + `OPENCODE_CONFIG_CONTENT` intact with `openrouter` added *beside* the pre-existing + provider. **So failproofai's existing per-CLI hooks keep enforcing under `ori ` + and we deliberately ship nothing for that path.** (`ori hermes` is unverified — hermes + was not installed on the probe box.) This matters because it is the exact bug class + from the grok integration, where our Claude hooks ran *inert* inside another CLI. + +2. **ori's own agent** — bare `ori` / `ori code`, the built-in `@ori-runloop/agent-loop` + harness. That is what this integration gates. + +**Enforcement is via published EXTENSION POINTS, not hook events.** A feature is a +workspace package under the global workspace's `features/`, **auto-discovered with no +config file to register it in** (like Goose's dropped plugin dir, unlike OpenCode which +must be named in `opencode.json`). `failproofai policies --install --cli ori` generates +`~/.ori/global/features/failproofai/{feature.ts,package.json}` plus a `failproofai.json` +that exists only to give the `Integration` interface a settings path — ori ignores it. +**User scope only:** bare `ori` boots the **global** workspace rather than the project's, +so one install covers every project and a project-scope install would never load. + +Three points, each `policy: "unique"` (one provider apiece — a competing feature displaces +us): + +| Point | Fires | On provider failure | +|-------|-------|---------------------| +| `approval-policy` | both modes — static `{defaultAction, rules[]}` | ignore-malformed | +| `approval-asker` | **manual mode only** — dynamic per-call callback | **deny** | +| `unattended-approvals` | unattended runs — dynamic per-call callback | **deny** | + +**The mode caveat is the whole story for coverage.** ori's approval mode defaults to +`self-drive`, which "approves every command without prompting", and in that mode the +**dynamic points are never called**. Isolated three ways: no callback fired under +self-drive; still none after claiming `approval-policy` with `defaultAction:"ask"` (so +`ask` degrades to auto-approve); but `defaultAction:"reject"` **did** block every tool +call — which proves the static point is wired and that self-drive skips specifically the +dynamic asker. So failproofai's per-argument policies enforce on ori only under +`--approvals manual` (or `/approvals` in the TUI). **There is no config key or env var to +change that default** — searched `config.json`, `ori.md` frontmatter, the `ORI_*` env +surface and the shipped selfdev docs. We claim `approval-policy` with an inert `ask` +rather than a blanket `reject` on purpose: claiming it with `reject` would brick every +self-drive session the moment failproofai is installed. + +**ori is the only integration that fails closed for free.** Both dynamic points declare +`failureBehavior: "deny"` — *"a throwing, rejecting, or malformed provider denies the +request"* — so the generated shim lets errors propagate instead of swallowing them, and a +failproofai fault blocks the call. Goose and OpenClaw fail **open** today. +`FAILPROOFAI_ORI_FAIL_OPEN=1` opts out. + +**Gate payload** (captured off real tool calls; `ORI_TOOL_MAP` / `ORI_TOOL_INPUT_MAP` in +`types.ts` are the canonicalization): + +| ori tool | arguments | capabilities | → canonical | +|----------|-----------|--------------|-------------| +| `bash` | `command` | execute, read, write | `Bash` (already canonical) | +| `read` | `path` | read | `Read` (`path`→`file_path`) | +| `write` | `path`, `content` | write | `Write` (`path`→`file_path`) | +| `glob` | `pattern` | read | `Glob` (already canonical) | +| `grep` | `pattern`, `path` | read | `Grep` (already canonical) | +| `edit` | `patch` | read, write | `Edit` — **see below** | + +Two properties that bite. The gate **fires twice per tool call** — `escalated:false`, then +`escalated:true` with a synthetic `{name:"escalated",value:"true"}` argument the shim +drops from the tool input, so policies must be idempotent across the ladder. And +`arguments` is a flat name/value **string** array, so every value arrives stringified. + +**`edit` is the sharp edge.** It carries the entire change as one `patch` string in OpenAI +apply_patch format (`*** Begin Patch` / `*** Update File: ` / `*** End Patch`) with +**no path argument at all** — so `file_path`, which `block-env-files`, +`block-secrets-write` and every other path builtin reads, was simply absent and those +builtins would have silently no-opped on every edit. `oriPatchFilePaths()` in +`tool-name-canonicalize.ts` recovers it from the header and also exposes +`ori_patch_files`. **KNOWN GAP:** a multi-file patch yields several paths and `file_path` +holds one, so a builtin that would have denied on a later file does not fire; asserted in +`__tests__/hooks/ori-canonicalize.test.ts` so it cannot rot into a silent surprise. + +The verdict shape is `{outcome:"allow"|"deny"}` — **no reason string reaches the model**, +so a denial arrives as a bare tool failure, `instruct()` degrades to allow + a stderr note +(as on Goose and Hermes), and **there is no `Stop` event at all**, leaving the 5 +`require-*-before-stop` builtins **inapplicable** on ori. + +**Audit pillar.** The transcript is SQLite at `~/.ori/global/.ori/state.sqlite`: +`ori_agent_loop_sessions` (session_id, title, **cwd**, model, turns, cost_usd, archived, +first_prompt, parent_session_id) and `ori_agent_loop_history`, whose **misnamed `prompt` +column holds the entire serialized conversation** — `{"content":[{role,content},…]}` with +Claude-style typed blocks (`{type:"reasoning"|"text"}`, `{type:"tool-call",id,name,params}`) +and results in `role:"tool"` messages, paired by `call-` id. Three plausible stores +are dead ends, recorded so nobody re-derives them: the session dir's `metadata.json` is a +summary with **no messages** even for a successful tool-using run; `code-*.jsonl` is +lifecycle logging with **zero** tool records; and the rich `AgentRuntimeEvent` stream +exists only transiently on `ori code --output jsonl` stdout. `lib/ori-sessions.ts` (pure +parser) + `lib/ori-projects.ts`; `ORI_HOME` / `ORI_DB_PATH` override for tests. +**Inherent limitation:** ori stores no per-message timestamp, so every entry carries the +session's `updated_at` and per-message timing is unrecoverable. + +For production users the recommended Ori install is: +```bash +failproofai policies --install --cli ori --scope user +``` + ### Dogfood configs for Factory / Devin / Antigravity / Goose Like the Codex / Cursor / OpenCode / Pi setups above, this repo ships -**project-scope dogfood configs** for the four newest CLIs so failproofai +**project-scope dogfood configs** for the six newest CLIs so failproofai enforces on itself when you drive this repo with them. Each uses the dev `node scripts/dev-hook.mjs --hook --cli ` command (never the `npx` production form — same self-reference caveat as the others): @@ -833,6 +1291,8 @@ production form — same self-reference caveat as the others): | Devin | `.devin/config.json` | Claude `"hooks"` wrapper | | Antigravity (`agy`) | `.agents/hooks.json` | named-hook schema under the `failproofai` key | | Goose | `.agents/plugins/failproofai/hooks/hooks.json` | Open Plugins (auto-discovered; matcher omitted — a bare `*` matches nothing) | +| grok | `.grok/hooks/failproofai.json` | Claude nested schema, seconds timeout, all 14 events (matcher omitted; needs `grok --trust` once, and only works because this repo is a git repo) | +| Qwen (`qwen`) | `.qwen/settings.json` | Claude `"hooks"` wrapper, **milliseconds** timeout, 19 events | These were generated from each integration's own `writeHookEntries`, so they track the live schema. See each CLI's architecture section above for the full @@ -1290,9 +1750,17 @@ Each entry should be a single line: a short description followed by the PR numbe ## Version bumps -When bumping the version, update **only** `package.json` (root). The CI version-consistency -check compares `packages/*/package.json` against root — that directory does not currently -exist, so no other files need updating. +When bumping the version, four files move together — this used to say "only +`package.json`", which was true before the Rust workspace existed and is now a red CI: + +| File | Why | +|------|-----| +| `package.json` (root) | the source of truth | +| `Cargo.toml` | `ci.yml`'s quality job compares the workspace version against root `package.json` and fails on a mismatch | +| `Cargo.lock` | pins all three workspace crates; regenerate with `cargo metadata --offline` rather than hand-editing | +| `CHANGELOG.md` heading | the `## ` section must name the new version | + +`packages/*/package.json` is also compared, but that directory still does not exist. That is the **npm** version, and it governs the CLI, the daemon and the Cargo workspace. The two Python packages version **independently of it and of each other** — `fp-cloud-cli` and diff --git a/Cargo.lock b/Cargo.lock index f478c6310..38b2b9046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,7 +202,7 @@ dependencies = [ [[package]] name = "failproofaid" -version = "1.0.4-beta.0" +version = "1.0.4-beta.1" dependencies = [ "fpai-collect", "fpai-ipc", @@ -265,7 +265,7 @@ dependencies = [ [[package]] name = "fpai-collect" -version = "1.0.4-beta.0" +version = "1.0.4-beta.1" dependencies = [ "notify", "reqwest", @@ -280,7 +280,7 @@ dependencies = [ [[package]] name = "fpai-ipc" -version = "1.0.4-beta.0" +version = "1.0.4-beta.1" dependencies = [ "libc", "proptest", diff --git a/Cargo.toml b/Cargo.toml index a084bf064..07370a307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "1.0.4-beta.0" +version = "1.0.4-beta.1" edition = "2024" license-file = "LICENSE" repository = "https://github.com/FailproofAI/failproofai" diff --git a/README.md b/README.md index d2be7306a..6f7793c9f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ before they execute. ## Supported harnesses -Twelve harnesses in two classes are supported: ten coding CLIs, plus two +Sixteen harnesses in two classes are supported: fourteen coding CLIs, plus two gateways: Hermes, OpenClaw. The policy API and session history are shared; which events can block varies by harness. @@ -38,9 +38,10 @@ Agents that run in none of them report through the [Python SDK](https://docs.bef which gives you tracing, sessions and audits. Enforcement there needs a hook in your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map it. - + + + - - + + + + + + + +
@@ -72,6 +73,8 @@ your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map i
@@ -88,8 +91,14 @@ your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map i
+ + + + grok CLI + + + @@ -98,6 +107,8 @@ your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map i
OpenClaw @@ -121,6 +132,8 @@ your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map i Antigravity CLI
@@ -129,6 +142,24 @@ your own runtime — [talk to us](mailto:support@befailproof.ai) and we'll map i + + Qwen Code + + + + + + Ori + + + + + Cline + +
@@ -276,7 +307,7 @@ own cluster is available on the Enterprise plan. |---|---| | [Quickstart](https://docs.befailproof.ai/start/quickstart) | Install, connect a harness, see the first run | | [Concepts](https://docs.befailproof.ai/start/concepts) | How the hook system works | -| [Supported harnesses](https://docs.befailproof.ai/reference/harnesses) | All 12, and what each one can enforce | +| [Supported harnesses](https://docs.befailproof.ai/reference/harnesses) | All 16, and what each one can enforce | | Observe | | |---|---| diff --git a/__tests__/audit/enabled-from-packs.test.ts b/__tests__/audit/enabled-from-packs.test.ts index f0d811e83..eb0c8da16 100644 --- a/__tests__/audit/enabled-from-packs.test.ts +++ b/__tests__/audit/enabled-from-packs.test.ts @@ -17,6 +17,14 @@ * its own history. This is only about which of its findings are already covered. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// Every test here calls `vi.resetModules()` and re-imports the whole audit +// module graph, so the cost grows with each integration we ship — the graph now +// pulls a sessions parser, a projects enumerator and an adapter per CLI. At 15 +// integrations the file runs ~3.4s alone and intermittently crossed the default +// 5s budget under a loaded parallel run. The assertions are unchanged; only the +// budget is, because the thing that got slower is the import, not the audit. +vi.setConfig({ testTimeout: 20_000 }); import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 3d662c3e2..cc5fd0aed 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -263,6 +263,10 @@ describe("ProjectList", () => { "Devin CLI", "Antigravity CLI", "Goose", + "grok CLI", + "Qwen Code", + "Ori", + "Cline", ]); }); diff --git a/__tests__/hooks/cli-flag-coverage.test.ts b/__tests__/hooks/cli-flag-coverage.test.ts new file mode 100644 index 000000000..dc23e663b --- /dev/null +++ b/__tests__/hooks/cli-flag-coverage.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node +/** + * `bin/failproofai.mjs` keeps its own hand-written lists of accepted `--cli` + * values, and they drift silently. + * + * Two of them were already wrong for `ori` when this test was written, and the + * failure modes are not symmetric: + * + * • The three `VALID_CLIS` sets REJECT an unlisted CLI outright — loud, so a + * human notices immediately (`Missing value(s) for --cli`). + * • The `--hook` guard FALLS BACK TO `"claude"` for anything unlisted. That + * one is silent: the hook runs, policies evaluate, activity is recorded, + * and the verdict is emitted in CLAUDE's wire shape to a CLI that cannot + * parse it. Installed, running, costing latency, enforcing nothing — the + * exact failure this repo has already shipped once, when our Claude hooks + * ran inert inside grok. + * + * Reading the source is deliberate. These lists cannot import INTEGRATION_TYPES + * (bin/ is plain JS that node must parse before any bundling), so the only way + * to keep them honest is to check the text against the source of truth. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { INTEGRATION_TYPES } from "@/src/hooks/types"; + +const BIN = readFileSync(resolve(process.cwd(), "bin/failproofai.mjs"), "utf8"); + +describe("bin/failproofai.mjs --cli lists cover every integration", () => { + it("INSTALLABLE_CLIS lists every integration", () => { + // The three `--cli` validators all read this one array, so it is the single + // place a new integration has to be added for `policies --install --cli ` + // to stop rejecting it. + const m = BIN.match(/const INSTALLABLE_CLIS = \[([^\]]*)\]/); + expect(m).not.toBeNull(); + const listed = [...m![1].matchAll(/"([^"]+)"/g)].map((x) => x[1]); + expect([...listed].sort()).toEqual([...INTEGRATION_TYPES].sort()); + }); + + it("every VALID_CLIS set is built from INSTALLABLE_CLIS, not a second hand-written list", () => { + // A literal here would drift from the array above the moment a CLI is added + // — which is exactly how grok and qwen ended up rejected by `--install`. + const literals = [...BIN.matchAll(/const VALID_CLIS = new Set\((\[[^\]]*\])\)/g)]; + expect(literals.map((m) => m[1])).toEqual([]); + }); + + it("the --hook guard lists every integration, so none silently becomes claude", () => { + // The guard is a chain of `cliArg === ""` comparisons ending in a + // `: "claude"` fallback. A CLI missing from the chain does not error — it + // is quietly evaluated and answered as Claude. + const guard = BIN.slice(0, BIN.indexOf('? cliArg\n : "claude"')); + const listed = new Set([...guard.matchAll(/cliArg === "([^"]+)"/g)].map((m) => m[1])); + const missing = INTEGRATION_TYPES.filter((c) => c !== "claude" && !listed.has(c)); + expect(missing).toEqual([]); + }); +}); diff --git a/__tests__/hooks/cline-batch-bypass.test.ts b/__tests__/hooks/cline-batch-bypass.test.ts new file mode 100644 index 000000000..993860dc3 --- /dev/null +++ b/__tests__/hooks/cline-batch-bypass.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment node +/** + * The tests that catch a SILENT BYPASS on a cline batch tool. + * + * These are the most important tests in the cline integration, and they are + * written against the REAL builtins rather than a stub policy, because the thing + * under test is not "does the fan-out loop run" — it is "does `block-sudo` + * actually deny when the sudo is at `commands[1]`". + * + * The failure they exist to prevent has shipped from this repo twice: a hook + * that is installed, runs, reports success, and enforces nothing. Cline makes it + * especially easy, because EVERY one of its tools is batch-shaped — a key rename + * leaves `tool_input.command` undefined and every command builtin allows. + * + * Each case therefore asserts BOTH halves: + * 1. the fan-out denies, AND + * 2. the naive collapse that fan-out replaced would NOT have — so if someone + * later "simplifies" this to a join, these tests fail loudly rather than + * going quietly green while enforcement disappears. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { clearPolicies } from "@/src/hooks/policy-registry"; +import { expandBatchToolInput, canonicalizeClineToolInput, BATCH_JOIN } from "@/src/hooks/batch-expand"; +import { evaluateExpandedBatch } from "@/src/hooks/batch-fanout"; +import type { SessionMetadata } from "@/src/hooks/types"; + +const session: SessionMetadata = { + sessionId: "t", + transcriptPath: "/dev/null", + cwd: "/repo", + cli: "cline", +}; + +/** Register a real builtin through the real registration path, params and all — + * a stub policy would prove the loop runs but not that `block-sudo` actually + * denies, which is the entire point of this file. */ +async function useBuiltin(name: string) { + const { registerBuiltinPolicies } = await import("@/src/hooks/builtin-policies"); + registerBuiltinPolicies([name]); +} + +/** Run the real fan-out path for a cline PreToolUse batch. */ +async function fanout(toolName: string, rawInput: Record) { + const expansion = expandBatchToolInput("cline", toolName, rawInput); + expect(expansion, "expansion must not be null — a null here means NOTHING is evaluated").not.toBeNull(); + const payload = { hook_event_name: "PreToolUse", tool_name: toolName, tool_input: rawInput }; + return evaluateExpandedBatch("PreToolUse", payload, session, undefined, expansion!, new Set()); +} + +beforeEach(() => clearPolicies()); + +describe("cline batch fan-out — command bypasses", () => { + it("denies sudo hiding at commands[1], not just commands[0]", async () => { + await useBuiltin("block-sudo"); + const r = await fanout("Bash", { commands: ["ls -la", "sudo rm -rf /tmp/x"] }); + expect(r.decision).toBe("deny"); + expect(r.batch.decidedIndex).toBe(1); + }); + + it("denies sudo at the LAST position of a long list", async () => { + await useBuiltin("block-sudo"); + const commands = [...Array.from({ length: 8 }, (_, i) => `echo ${i}`), "sudo shutdown"]; + const r = await fanout("Bash", { commands }); + expect(r.decision).toBe("deny"); + expect(r.batch.decidedIndex).toBe(8); + }); + + it("reports the LOWEST offending index when several elements would deny", async () => { + await useBuiltin("block-sudo"); + const r = await fanout("Bash", { commands: ["ok", "sudo a", "sudo b"] }); + expect(r.decision).toBe("deny"); + expect(r.batch.decidedIndex).toBe(1); + // Short-circuits: element 2 is never evaluated. + expect(r.batch.evaluated).toBe(2); + }); + + it("allows a batch where no element offends", async () => { + await useBuiltin("block-sudo"); + const r = await fanout("Bash", { commands: ["ls", "pwd", "echo hi"] }); + expect(r.decision).toBe("allow"); + expect(r.batch.evaluated).toBe(3); + expect(r.batch.decidedIndex).toBeNull(); + }); +}); + +describe("cline batch fan-out — path bypasses", () => { + it("denies a .env hiding at files[1] — the case element-[0] canonicalization misses", async () => { + await useBuiltin("block-env-files"); + const r = await fanout("Read", { files: [{ path: "/repo/safe.ts" }, { path: "/repo/.env" }] }); + expect(r.decision).toBe("deny"); + expect(r.batch.decidedIndex).toBe(1); + }); + + it("denies a .env at files[0] too", async () => { + await useBuiltin("block-env-files"); + const r = await fanout("Read", { files: [{ path: "/repo/.env" }, { path: "/repo/safe.ts" }] }); + expect(r.decision).toBe("deny"); + expect(r.batch.decidedIndex).toBe(0); + }); +}); + +describe("cline batch fan-out — apply_patch", () => { + const MULTI = [ + "*** Begin Patch", + "*** Update File: /repo/safe.ts", + "@@", + "-a", + "+b", + "*** Update File: /repo/.env", + "@@", + "-SECRET=old", + "+SECRET=new", + "*** End Patch", + ].join("\n"); + + it("splits a multi-file patch and denies on the SECOND file", async () => { + await useBuiltin("block-env-files"); + const r = await fanout("Edit", { input: MULTI }); + expect(r.decision).toBe("deny"); + expect(r.batch.size).toBe(2); + expect(r.batch.decidedIndex).toBe(1); + }); + + it("gives each patched file its own old_string/new_string", async () => { + // ori's Edit sets NEITHER, so block-secrets-write can never fire on it. + // Cline's does, because the splitter reconstructs them per file. + const exp = expandBatchToolInput("cline", "Edit", { input: MULTI })!; + expect(exp.elements).toHaveLength(2); + expect(exp.elements[0].input).toMatchObject({ file_path: "/repo/safe.ts", old_string: "a", new_string: "b" }); + expect(exp.elements[1].input).toMatchObject({ + file_path: "/repo/.env", + old_string: "SECRET=old", + new_string: "SECRET=new", + }); + }); +}); + +describe("why fan-out exists — the collapse would NOT have caught these", () => { + it("PROOF: SECRET_FILE_RE is $-anchored, so a joined path list only matches the LAST entry", async () => { + // This is the concrete reason join-to-scalar was rejected. If someone + // "simplifies" the fan-out into a join, this test documents what breaks. + const { SECRET_FILE_RE } = await import("@/src/hooks/risk-patterns"); + const paths = ["/repo/deploy.pem", "/repo/readme.md"]; + expect(SECRET_FILE_RE.test(paths[0])).toBe(true); + expect(SECRET_FILE_RE.test(paths.join("\n"))).toBe(false); + expect(SECRET_FILE_RE.test(paths.join(" "))).toBe(false); + }); + + it("the collapse safety net still picks the RISKIEST path, not paths[0]", async () => { + // The net cannot be as strong as fan-out, but it must not be naive either. + const out = canonicalizeClineToolInput("Read", { + files: [{ path: "/repo/safe.ts" }, { path: "/repo/.env" }], + }); + expect(out.file_path).toBe("/repo/.env"); + expect(out.cline_file_paths).toEqual(["/repo/safe.ts", "/repo/.env"]); + }); + + it("a collapsed command list keeps `&&` boundaries alive for the segmenting builtins", async () => { + // A bare "\n" join silently disables READ_LIKE_CMDS, whose boundary + // alternation has no newline in it. + expect(BATCH_JOIN).toContain("&&"); + const out = canonicalizeClineToolInput("Bash", { commands: ["cat /etc/passwd", "ls"] }); + expect(String(out.command)).toContain("&&"); + expect(out.cline_commands).toEqual(["cat /etc/passwd", "ls"]); + }); +}); + +describe("cline batch fan-out — shapes and edges", () => { + it("emits cline's {cancel,errorMessage} deny shape carrying the element locator", async () => { + await useBuiltin("block-sudo"); + const r = await fanout("Bash", { commands: ["ls", "sudo rm -rf /tmp/x"] }); + expect(r.exitCode).toBe(0); // cline IGNORES the exit code + const out = JSON.parse(r.stdout as string); + expect(out.cancel).toBe(true); + expect(typeof out.errorMessage).toBe("string"); + expect(out.errorMessage).toContain("batch 2/2"); + expect(out.decision).toBeUndefined(); // no such field in cline's schema + }); + + it("returns null — never an empty expansion — for a mis-shaped container", () => { + // A zero-element expansion would evaluate NOTHING and report a clean allow, + // which is the exact failure this whole file exists to prevent. + expect(expandBatchToolInput("cline", "Bash", { commands: [] })).toBeNull(); + expect(expandBatchToolInput("cline", "Bash", { commands: "not-an-array" })).toBeNull(); + expect(expandBatchToolInput("cline", "Read", { files: [{ no_path: 1 }] })).toBeNull(); + expect(expandBatchToolInput("cline", "Edit", { input: 42 })).toBeNull(); + }); + + it("does not expand for any other CLI — the other 15 keep the single-shot path", () => { + for (const cli of ["claude", "codex", "ori", "goose", "grok"] as const) { + expect(expandBatchToolInput(cli, "Bash", { commands: ["sudo x"] })).toBeNull(); + } + }); +}); diff --git a/__tests__/hooks/cline-canonicalize.test.ts b/__tests__/hooks/cline-canonicalize.test.ts new file mode 100644 index 000000000..7e04be95c --- /dev/null +++ b/__tests__/hooks/cline-canonicalize.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment node +/** + * Cline's wire contract, pinned against what was captured off a LIVE run. + * + * Every expectation here is a transcription of a real cline v3.0.60 payload or + * a real observed behaviour, not a reading of its docs — which is the point, + * because the docs describe hook policies (`failureMode`, `mode: blocking`) that + * the shipped CLI does not have, and describe the hook surface in terms of an + * SDK plugin API that cannot register a tool gate at all. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { canonicalizeToolName, canonicalizeToolInput } from "@/src/hooks/tool-name-canonicalize"; +import { normalizeCliPayload } from "@/src/hooks/normalize-cli-payload"; +import { evaluatePolicies } from "@/src/hooks/policy-evaluator"; +import { registerPolicy, clearPolicies } from "@/src/hooks/policy-registry"; +import { deny, instruct } from "@/src/hooks/policy-helpers"; +import { CLINE_EVENT_MAP, CLINE_HOOK_EVENT_TYPES } from "@/src/hooks/types"; +import { splitApplyPatch } from "@/src/hooks/batch-expand"; +import type { SessionMetadata } from "@/src/hooks/types"; + +const session: SessionMetadata = { + sessionId: "t", + transcriptPath: "/dev/null", + cwd: "/repo", + cli: "cline", +}; + +describe("cline tool names", () => { + it.each([ + ["run_commands", "Bash"], + ["read_files", "Read"], + ["search_codebase", "Grep"], + ["apply_patch", "Edit"], + ])("maps %s → %s", (raw, canonical) => { + expect(canonicalizeToolName(raw, "cline")).toBe(canonical); + }); + + it("passes an unknown tool through so it still reaches the audit", () => { + expect(canonicalizeToolName("browser_action", "cline")).toBe("browser_action"); + }); +}); + +describe("cline event map", () => { + it("excludes PreCompact — it maps to undefined upstream and is skipped", () => { + // Installing a file for it costs a subprocess per compaction and buys + // nothing, so it is deliberately not one of the nine we write. + expect(CLINE_HOOK_EVENT_TYPES).not.toContain("PreCompact"); + expect(CLINE_HOOK_EVENT_TYPES).toHaveLength(9); + }); + + it("maps every installed event onto an existing canonical event", async () => { + const { HOOK_EVENT_TYPES } = await import("@/src/hooks/types"); + for (const e of CLINE_HOOK_EVENT_TYPES) { + expect(HOOK_EVENT_TYPES).toContain(CLINE_EVENT_MAP[e]); + } + }); + + it("collapses the two session-ending events onto SessionEnd", () => { + expect(CLINE_EVENT_MAP.TaskCancel).toBe("SessionEnd"); + expect(CLINE_EVENT_MAP.SessionShutdown).toBe("SessionEnd"); + expect(CLINE_EVENT_MAP.TaskStart).toBe("SessionStart"); + expect(CLINE_EVENT_MAP.TaskResume).toBe("SessionStart"); + }); +}); + +describe("cline payload normalization", () => { + /** Captured verbatim from a live PreToolUse hook on cline v3.0.60. */ + const LIVE = { + hookName: "tool_call", + iteration: 1, + taskId: "conv_1788255622579_zzx8kw9", + userId: "chetan", + workspaceRoots: ["/repo"], + workspaceInfo: { rootPath: "/repo", hint: "repo" }, + agent_id: "agent_1", + parent_agent_id: null, + sessionContext: { rootSessionId: "1788255622392_vg8rv" }, + tool_call: { id: "call_9Nc", name: "run_commands", input: { commands: ["echo hookprobe"] } }, + preToolUse: { toolName: "run_commands", parameters: { commands: '["echo hookprobe"]' } }, + }; + + it("reads tool_call.input, NOT preToolUse.parameters", () => { + // preToolUse.parameters JSON-STRINGIFIES arrays. Reading it would hand the + // batch expander a string and silently drop the entire fan-out. + const p: Record = { ...LIVE }; + normalizeCliPayload("cline", p); + expect(p.tool_name).toBe("run_commands"); + expect(p.tool_input).toEqual({ commands: ["echo hookprobe"] }); + expect(Array.isArray((p.tool_input as Record).commands)).toBe(true); + }); + + it("derives session_id from taskId and cwd from workspaceRoots[0]", () => { + const p: Record = { ...LIVE }; + normalizeCliPayload("cline", p); + expect(p.session_id).toBe("conv_1788255622579_zzx8kw9"); + expect(p.cwd).toBe("/repo"); + }); + + it("falls back to workspaceInfo.rootPath when workspaceRoots is absent", () => { + const p: Record = { ...LIVE, workspaceRoots: undefined }; + normalizeCliPayload("cline", p); + expect(p.cwd).toBe("/repo"); + }); + + it("falls back to preToolUse only when tool_call is missing entirely", () => { + const p: Record = { ...LIVE, tool_call: undefined }; + normalizeCliPayload("cline", p); + expect(p.tool_name).toBe("run_commands"); + }); + + it("does NOT map hookName onto hook_event_name", () => { + // hookName is cline's INTERNAL name ("tool_call"), not the filename event; + // the --hook argument already carries the canonical one. + const p: Record = { ...LIVE }; + normalizeCliPayload("cline", p); + expect(p.hook_event_name).toBeUndefined(); + }); +}); + +describe("cline apply_patch splitting", () => { + it("keeps paths in literal file order across Add / Delete / Move", () => { + // Move-to is a rename MODIFIER: the section being renamed flushes first, so + // `Update File: a` + `Move to: b` yields [a, b] and never [b, a]. + const patch = [ + "*** Begin Patch", + "*** Add File: new.ts", + "*** Update File: a.ts", + "@@", + "-x", + "+y", + "*** Move to: b.ts", + "*** End Patch", + ].join("\n"); + expect(splitApplyPatch(patch).map((s) => s.path)).toEqual(["new.ts", "a.ts", "b.ts"]); + }); + + it("sees a rename INTO a sensitive path as its own element", () => { + const patch = ["*** Begin Patch", "*** Update File: ok.ts", "@@", "-x", "+y", "*** Move to: .env", "*** End Patch"].join("\n"); + expect(splitApplyPatch(patch).map((s) => s.path)).toContain(".env"); + }); +}); + +describe("cline verdict shapes", () => { + beforeEach(() => clearPolicies()); + + it("deny emits {cancel:true, errorMessage} at exit 0 — cline ignores the exit code", async () => { + registerPolicy("test/blocker", "blocks", async () => deny("nope"), {}, 0); + const r = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, session, undefined); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout as string); + expect(out).toMatchObject({ cancel: true }); + expect(typeof out.errorMessage).toBe("string"); + // cline's schema has no decision/block/permissionDecision field at all. + expect(out.decision).toBeUndefined(); + expect(out.permissionDecision).toBeUndefined(); + }); + + it("Stop does NOT cancel — that would kill an already-completed run", async () => { + registerPolicy("test/blocker", "blocks", async () => deny("finish up"), { events: ["Stop"] }, 0); + const r = await evaluatePolicies("Stop", {}, session, undefined); + const out = JSON.parse(r.stdout as string); + expect(out.cancel).toBeUndefined(); + expect(typeof out.context).toBe("string"); + expect(out.context).toContain("MANDATORY ACTION REQUIRED"); + }); + + it("instruct uses cline's real context channel, and mirrors to stderr", async () => { + registerPolicy("test/instructor", "instructs", async () => instruct("do the thing"), {}, 0); + const r = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, session, undefined); + const out = JSON.parse(r.stdout as string); + expect(out.context).toContain("do the thing"); + expect(r.stderr).toContain("do the thing"); + }); +}); + +describe("cline safety net (non-fan-out paths)", () => { + it("collapses search queries into one alternation", () => { + const out = canonicalizeToolInput("Grep", { queries: ["alpha", "beta"] }, "cline") as Record; + expect(out.pattern).toBe("alpha|beta"); + expect(out.cline_queries).toEqual(["alpha", "beta"]); + }); + + it("leaves an unrecognised cline tool input untouched", () => { + expect(canonicalizeToolInput("browser_action", { url: "x" }, "cline")).toEqual({ url: "x" }); + }); +}); diff --git a/__tests__/hooks/dogfood-configs.test.ts b/__tests__/hooks/dogfood-configs.test.ts index f0b07c15d..4178e0eec 100644 --- a/__tests__/hooks/dogfood-configs.test.ts +++ b/__tests__/hooks/dogfood-configs.test.ts @@ -64,6 +64,8 @@ const CONFIGS = [ { file: ".devin/config.json", cli: "devin", count: 7 }, { file: ".agents/hooks.json", cli: "antigravity", count: 4 }, { file: ".agents/plugins/failproofai/hooks/hooks.json", cli: "goose", count: 5 }, + { file: ".grok/hooks/failproofai.json", cli: "grok", count: 14 }, + { file: ".qwen/settings.json", cli: "qwen", count: 19 }, ]; describe.each(CONFIGS)("$file", ({ file, cli, count }) => { diff --git a/__tests__/hooks/fail-closed-force-decision.test.ts b/__tests__/hooks/fail-closed-force-decision.test.ts index 34bd1f0bf..25e53d229 100644 --- a/__tests__/hooks/fail-closed-force-decision.test.ts +++ b/__tests__/hooks/fail-closed-force-decision.test.ts @@ -166,12 +166,16 @@ describe("the fail-closed verdict is enforcing on every supported CLI", () => { const CLIS = [ "claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", + "grok", "qwen", ] as const; // The CLIs that read their verdict from stdout JSON and IGNORE the exit code. // For these, an empty stdout is not a weak deny — it is an allow. const STDOUT_DRIVEN = [ "cursor", "pi", "hermes", "openclaw", "devin", "antigravity", "goose", + // grok reads {decision:"deny"} off stdout and ignores the exit code; qwen + // reads Claude's hookSpecificOutput shape there. Both verified live. + "grok", "qwen", ] as const; it.each(CLIS)("%s receives a verdict that actually enforces", async (cli) => { diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts index 5e82044ec..69e3d2f8e 100644 --- a/__tests__/hooks/fp-reset.test.ts +++ b/__tests__/hooks/fp-reset.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as daemonService from "@/src/hooks/daemon-service"; import { mkdtempSync, rmSync, @@ -540,12 +541,37 @@ describe("checkLayoutForCli", () => { // its next reboot, and a daemon-configured machine that cannot reach its // daemon denies every tool call. describe("the daemon warning on the branch that migrates", () => { + /** Collapse the warning's terminal hard-wrapping so a phrase that straddles + * a newline still matches. */ + function unwrapped(text: string): string { + return text.replace(/\s+/g, " "); + } + /** A managed install of `ver`, which is what `daemonVersionSkew()` reads. */ function installedDaemon(ver: string) { mkdirSync(binDir(), { recursive: true }); writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF"); } + // `daemonServiceStatus()` reads the HOST's systemd unit, which no + // FAILPROOFAI_HOME can sandbox — so on a developer machine that has run + // `failproofai config`, `healDaemonFlag()` sees a real running service, + // fails its probe against this temp home's absent socket, and prints its + // own "cannot evaluate policies" paragraph instead. That both clears + // `daemon.configured` and drowns out the skew warning these tests are + // about, so they passed or failed depending on whether the developer + // happened to have a daemon installed. + // + // Pinned to "stopped": the one status `healDaemonFlag` deliberately ignores + // (a stopped service is usually a restart in progress), which leaves + // `staleDaemonHint()` as the only thing writing lines here. + beforeEach(() => { + vi.spyOn(daemonService, "daemonServiceStatus").mockReturnValue("stopped"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("warns hard when the machine REQUIRES a daemon that will not start", async () => { seedLayoutOne(); installedDaemon("0.0.1-old"); @@ -557,7 +583,9 @@ describe("checkLayoutForCli", () => { expect(text).toContain("0.0.1-old"); // Must name the consequence, not just the mismatch: the reason to act now // rather than at the next reboot is that the next reboot is the failure. - expect(text).toMatch(/denies every tool call/i); + // Matched on whitespace-normalised text because the warning is + // hard-wrapped for the terminal, so the phrase straddles a newline. + expect(unwrapped(text)).toMatch(/denies every tool call/i); // And the command that actually fixes it. `failproofai config` was the // old advice and rebuilds the service rather than updating the binary. expect(text).toContain("failproofai update"); @@ -574,7 +602,9 @@ describe("checkLayoutForCli", () => { const text = (await checkLayoutForCli()).lines.join("\n"); expect(text).toContain("0.0.1-old"); - expect(text).not.toMatch(/denies every tool call/i); + // Normalised for the same reason as above — a raw-text negative would + // pass merely because the phrase happened to wrap. + expect(unwrapped(text)).not.toMatch(/denies every tool call/i); }); it("says nothing about the daemon when there is no skew", async () => { diff --git a/__tests__/hooks/generated-shim-fail-closed.test.ts b/__tests__/hooks/generated-shim-fail-closed.test.ts new file mode 100644 index 000000000..d15282cc8 --- /dev/null +++ b/__tests__/hooks/generated-shim-fail-closed.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment node +/** + * The generated launcher scripts, checked as SHIPPED ARTIFACTS. + * + * These files are strings built at install time, so nothing else typechecks or + * exercises them — which is how a fail-open crept into the ori feature: its + * `close` handler took no arguments, so a failproofai binary that crashed + * before writing stdout resolved to `{permission:"allow"}`. Ori's + * `failureBehavior:"deny"` could never fire on that path, because RESOLVING is + * what tells ori the provider succeeded; only a rejection reaches the guarantee. + * The integration claimed to fail closed and, on the one path that matters when + * failproofai is broken, did the opposite. + * + * SCOPE, stated honestly: these assert on the generated SOURCE, not on a live + * ori runtime — the feature imports `ori`, which only exists inside ori's own + * process. They are a regression guard on the artifact we write to disk, and + * they would catch the exact reintroduction of the bug above. They do not prove + * ori honours the rejection; that was verified by hand against ori 0.12.0. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ori, cline } from "@/src/hooks/integrations"; + +const made: string[] = []; +function tempHome(envVar: "ORI_HOME" | "CLINE_HOME"): string { + const dir = mkdtempSync(join(tmpdir(), "fp-shim-")); + made.push(dir); + process.env[envVar] = dir; + return dir; +} + +afterEach(() => { + delete process.env.ORI_HOME; + delete process.env.CLINE_HOME; + for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("generated ori feature fails CLOSED on a broken failproofai", () => { + function generate(): string { + const home = tempHome("ORI_HOME"); + ori.writeHookEntries({}, "/usr/local/bin/failproofai", "user"); + const path = join(home, "global", "features", "failproofai", "feature.ts"); + expect(existsSync(path)).toBe(true); + return readFileSync(path, "utf8"); + } + + it("inspects the child's exit code and signal", () => { + const src = generate(); + expect(src).toMatch(/child\.on\("close",\s*\(code,\s*signal\)/); + }); + + it("treats a non-zero exit or a signal as a failure, not an allow", () => { + const src = generate(); + // The guard must appear BEFORE the empty-stdout allow, or a crashed binary + // with no output takes the allow path first and the guard is dead code. + const guard = src.search(/if \(signal \|\| \(typeof code === "number" && code !== 0\)\)/); + const emptyAllow = src.search(/if \(!stdout\) \{ finish\(done, \{ permission: "allow" \}\); return; \}/); + expect(guard).toBeGreaterThan(-1); + expect(emptyAllow).toBeGreaterThan(-1); + expect(guard).toBeLessThan(emptyAllow); + }); + + it("rejects a verdict that is neither an explicit allow nor an explicit deny", () => { + // decide() maps every non-"deny" to allow, so an unvalidated object would + // silently become one. + const src = generate(); + expect(src).toMatch(/verdict\.permission !== "allow" && verdict\.permission !== "deny"/); + }); + + it("still treats exit 0 with empty stdout as a real allow", () => { + // That IS the evaluator's clean-allow shape; rejecting it would deny every + // allowed tool call on a working install. + const src = generate(); + expect(src).toMatch(/if \(!stdout\) \{ finish\(done, \{ permission: "allow" \}\); return; \}/); + }); + + it("routes every failure through FAIL_OPEN so the escape hatch still works", () => { + const src = generate(); + // Each failure branch offers the documented opt-out rather than hardcoding + // a deny — an operator who cannot afford a failproofai fault to stop their + // agent needs a way out that does not mean uninstalling. + const branches = src.match(/FAIL_OPEN \? finish\(done, \{ permission: "allow" \}\) : finish\(fail,/g); + expect(branches?.length ?? 0).toBeGreaterThanOrEqual(4); + }); +}); + +describe("generated cline launcher always emits exactly one JSON object", () => { + it("prints {} when failproofai produces nothing", () => { + // Cline IGNORES the exit code and reads stdout only. Unparseable or empty + // stdout makes it skip the hook and run the tool, so the launcher must + // always print one object — cline is fail-open with no opt-out, and this is + // the one place we can keep that from becoming garbled output. + const home = tempHome("CLINE_HOME"); + cline.writeHookEntries({}, "/usr/local/bin/failproofai", "user"); + const src = readFileSync(join(home, "hooks", "PreToolUse.sh"), "utf8"); + expect(src).toContain('verdict=\'{}\''); + expect(src).toMatch(/printf '%s\\n' "\$verdict"/); + expect(src).toContain("--hook PreToolUse --cli cline"); + }); + + it("names the file for the event, because the filename IS the subscription", () => { + const home = tempHome("CLINE_HOME"); + cline.writeHookEntries({}, "/usr/local/bin/failproofai", "user"); + for (const event of ["PreToolUse", "PostToolUse", "UserPromptSubmit", "TaskComplete"]) { + const path = join(home, "hooks", `${event}.sh`); + expect(existsSync(path), `${event}.sh must exist`).toBe(true); + expect(readFileSync(path, "utf8")).toContain(`--hook ${event} --cli cline`); + } + // PreCompact maps to undefined upstream and is skipped at dispatch. + expect(existsSync(join(home, "hooks", "PreCompact.sh"))).toBe(false); + }); +}); diff --git a/__tests__/hooks/grok-qwen-canonicalize.test.ts b/__tests__/hooks/grok-qwen-canonicalize.test.ts new file mode 100644 index 000000000..ed762dfbd --- /dev/null +++ b/__tests__/hooks/grok-qwen-canonicalize.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node +// +// Locks in the grok + qwen wire contracts, all captured from live recorder +// hooks (grok 1.0.3 / qwen-code 0.21.12) rather than read off either CLI's +// docs — which matters, because in grok's case the docs were wrong twice. +// +// The cases that are NOT cosmetic, and why each is here: +// • grok's `read_file` delivers `target_file`, so without the input map a +// live `.env` read walks past block-env-files (the Copilot bug, again). +// • grok pipes camelCase, so without normalization `tool_name`/`tool_input` +// reach every builtin as undefined. +// • grok EXECUTES `.claude/settings.json` — the file our own claude install +// writes — so a grok payload can arrive on a hook flagged `--cli claude`. +import { describe, it, expect } from "vitest"; +import { canonicalizeToolName, canonicalizeToolInput } from "@/src/hooks/tool-name-canonicalize"; +import { + normalizeCliPayload, + isGrokEnvelope, + resolveEffectiveCli, +} from "@/src/hooks/normalize-cli-payload"; +import { + GROK_HOOK_EVENT_TYPES, + GROK_TOOL_INPUT_MAP, + QWEN_HOOK_EVENT_TYPES, + QWEN_EVENT_MAP, + HOOK_EVENT_TYPES, +} from "@/src/hooks/types"; +import { canonicalizeEventType } from "@/src/hooks/handler"; + +/** A PreToolUse payload exactly as grok 1.0.3 pipes it (captured verbatim). */ +function grokPreToolUsePayload(): Record { + return { + hookEventName: "pre_tool_use", + sessionId: "01a00bc4-57ad-7231-98ef-9f037a781572", + cwd: "/tmp/fp-probe/ws", + workspaceRoot: "/tmp/fp-probe/ws", + transcriptPath: "/home/u/.grok/sessions/%2Ftmp/01a00bc4/chat_history.jsonl", + permissionMode: "bypassPermissions", + toolName: "run_terminal_command", + toolInput: { command: "echo FPPROBE", description: "Echo FPPROBE to stdout" }, + toolUseId: "call-ed78750f", + toolInputTruncated: false, + timestamp: "2026-08-16T18:10:12Z", + }; +} + +describe("grok + qwen event types", () => { + it("are all already-canonical PascalCase HookEventTypes (no event map needed)", () => { + const canonical = new Set(HOOK_EVENT_TYPES); + for (const ev of GROK_HOOK_EVENT_TYPES) { + expect(canonical.has(ev), `${ev} must be a HookEventType`).toBe(true); + } + // qwen is canonical too, EXCEPT its two Todo names, which QWEN_EVENT_MAP + // translates to TaskCreated/TaskCompleted. + for (const ev of QWEN_HOOK_EVENT_TYPES) { + const mapped = QWEN_EVENT_MAP[ev]; + expect(canonical.has(mapped), `${ev} must map to a HookEventType`).toBe(true); + } + }); + + it("both subscribe to Stop — each has a verified force-retry gate", () => { + // Unlike goose/hermes, both of these CAN keep the agent working, so the 5 + // require-*-before-stop builtins are applicable and Stop must be installed. + expect(GROK_HOOK_EVENT_TYPES).toContain("Stop"); + expect(QWEN_HOOK_EVENT_TYPES).toContain("Stop"); + }); + + it("grok subscribes to its ENTIRE event surface", () => { + // A live grok 1.0.3 accepted all 14 (`hook_count=14`, no unknown-key + // warning). grok silently skips names it doesn't recognize, so this list + // drifting out of sync would cost coverage with no error anywhere. + expect(GROK_HOOK_EVENT_TYPES).toHaveLength(14); + for (const e of ["PermissionDenied", "StopFailure", "Notification", "SubagentStart", "PreCompact", "PostCompact"]) { + expect(GROK_HOOK_EVENT_TYPES, `${e} must be installed`).toContain(e); + } + }); + + it("qwen omits the two events that would cost more than they return", () => { + // MessageDisplay fires per streaming chunk — a hook process per chunk. + // PostToolBatch fired 6× where PostToolUse fired 5, carrying the same tool + // calls in batch form, and no builtin reads it. Both are deliberate. + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("MessageDisplay"); + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("PostToolBatch"); + expect(QWEN_HOOK_EVENT_TYPES).not.toContain("SessionDelete"); + }); + + it("qwen's Todo events canonicalize onto the Task events", () => { + // qwen calls its task list "todos"; mapping them means a policy written + // against TaskCreated fires on qwen and Claude alike. + expect(canonicalizeEventType("TodoCreated", "qwen")).toBe("TaskCreated"); + expect(canonicalizeEventType("TodoCompleted", "qwen")).toBe("TaskCompleted"); + }); + + it("qwen's other 17 events canonicalize to themselves", () => { + for (const e of QWEN_HOOK_EVENT_TYPES) { + if (e === "TodoCreated" || e === "TodoCompleted") continue; + expect(canonicalizeEventType(e, "qwen"), e).toBe(e); + } + }); + + it("the qwen event map covers every installed event", () => { + // Exhaustive at the type level; assert it at runtime too, since a missing + // entry would write an `undefined` event key into a user's settings.json. + for (const e of QWEN_HOOK_EVENT_TYPES) { + expect(QWEN_EVENT_MAP[e], `${e} needs a canonical mapping`).toBeTruthy(); + } + }); + + it("grok needs no event map — its names are canonical already", () => { + for (const e of GROK_HOOK_EVENT_TYPES) { + expect(canonicalizeEventType(e, "grok"), e).toBe(e); + } + }); +}); + +describe("grok tool canonicalization", () => { + it("maps grok's tool ids to Claude builtins", () => { + expect(canonicalizeToolName("run_terminal_command", "grok")).toBe("Bash"); + expect(canonicalizeToolName("write", "grok")).toBe("Write"); + expect(canonicalizeToolName("read_file", "grok")).toBe("Read"); + expect(canonicalizeToolName("search_replace", "grok")).toBe("Edit"); + expect(canonicalizeToolName("grep", "grok")).toBe("Grep"); + expect(canonicalizeToolName("list_dir", "grok")).toBe("LS"); + }); + + it("also accepts the shell-tool name grok's headless doc uses", () => { + // The hooks doc says `run_terminal_command` (which is what the wire sends); + // the headless doc says `run_terminal_cmd`. Both canonicalize. + expect(canonicalizeToolName("run_terminal_cmd", "grok")).toBe("Bash"); + }); + + it("passes unknown tools through unchanged", () => { + expect(canonicalizeToolName("some_mcp__tool", "grok")).toBe("some_mcp__tool"); + }); + + it("maps read_file's target_file to file_path so path builtins fire", () => { + // THE load-bearing case: block-env-files / block-read-outside-cwd read + // `file_path`. Without this a live `.env` read is invisible to them. + expect(canonicalizeToolInput("Read", { target_file: ".env" }, "grok")).toEqual({ + file_path: ".env", + }); + }); + + it("maps list_dir's target_directory to path", () => { + expect(canonicalizeToolInput("LS", { target_directory: "/etc" }, "grok")).toEqual({ + path: "/etc", + }); + }); + + it("leaves already-canonical inputs untouched", () => { + // Bash/Write/Edit/Grep deliver canonical keys, so they have no map entry. + expect(GROK_TOOL_INPUT_MAP.Bash).toBeUndefined(); + const bash = { command: "rm -rf /" }; + expect(canonicalizeToolInput("Bash", bash, "grok")).toEqual(bash); + const edit = { file_path: "a.ts", old_string: "a", new_string: "b" }; + expect(canonicalizeToolInput("Edit", edit, "grok")).toEqual(edit); + }); +}); + +describe("qwen tool canonicalization", () => { + it("maps qwen's runtime tool ids to Claude builtins", () => { + expect(canonicalizeToolName("run_shell_command", "qwen")).toBe("Bash"); + expect(canonicalizeToolName("read_file", "qwen")).toBe("Read"); + expect(canonicalizeToolName("write_file", "qwen")).toBe("Write"); + expect(canonicalizeToolName("edit", "qwen")).toBe("Edit"); + expect(canonicalizeToolName("grep_search", "qwen")).toBe("Grep"); + expect(canonicalizeToolName("list_directory", "qwen")).toBe("LS"); + }); + + it("also accepts qwen's legacy display-name matcher aliases", () => { + expect(canonicalizeToolName("ReadFile", "qwen")).toBe("Read"); + expect(canonicalizeToolName("WriteFile", "qwen")).toBe("Write"); + }); + + it("needs NO input mapping — every qwen tool key is already canonical", () => { + const cases: Array<[string, Record]> = [ + ["Bash", { command: "echo hi" }], + ["Read", { file_path: "/tmp/a.txt" }], + ["Write", { file_path: "/tmp/a.txt", content: "x" }], + ["Edit", { file_path: "a.ts", old_string: "a", new_string: "b" }], + ["Grep", { pattern: "x", path: "." }], + ["LS", { path: "." }], + ]; + for (const [tool, input] of cases) { + expect(canonicalizeToolInput(tool, input, "qwen"), tool).toEqual(input); + } + }); +}); + +describe("grok payload normalization", () => { + it("maps the camelCase envelope onto the snake_case fields builtins read", () => { + const p = grokPreToolUsePayload(); + normalizeCliPayload("grok", p); + expect(p.tool_name).toBe("run_terminal_command"); + expect(p.tool_input).toEqual({ command: "echo FPPROBE", description: "Echo FPPROBE to stdout" }); + expect(p.session_id).toBe("01a00bc4-57ad-7231-98ef-9f037a781572"); + expect(p.permission_mode).toBe("bypassPermissions"); + expect(p.transcript_path).toBe( + "/home/u/.grok/sessions/%2Ftmp/01a00bc4/chat_history.jsonl", + ); + }); + + it("maps PostToolUse's toolResult onto tool_response (grok does not send Claude's key)", () => { + const p: Record = { + hookEventName: "post_tool_use", + workspaceRoot: "/w", + toolName: "run_terminal_command", + toolResult: { exit_code: 0, output_for_prompt: "hi\n" }, + }; + normalizeCliPayload("grok", p); + expect(p.tool_response).toEqual({ exit_code: 0, output_for_prompt: "hi\n" }); + }); + + it("falls back to workspaceRoot for cwd, without clobbering a real cwd", () => { + const withCwd = grokPreToolUsePayload(); + withCwd.cwd = "/real/cwd"; + normalizeCliPayload("grok", withCwd); + expect(withCwd.cwd).toBe("/real/cwd"); + + const noCwd = grokPreToolUsePayload(); + delete noCwd.cwd; + normalizeCliPayload("grok", noCwd); + expect(noCwd.cwd).toBe("/tmp/fp-probe/ws"); + }); + + it("does NOT map hookEventName — its value is snake_case, the --hook arg is canonical", () => { + const p = grokPreToolUsePayload(); + normalizeCliPayload("grok", p); + expect(p.hook_event_name).toBeUndefined(); + }); +}); + +describe("grok executing another CLI's hook config", () => { + it("recognizes grok's envelope", () => { + expect(isGrokEnvelope(grokPreToolUsePayload())).toBe(true); + }); + + it("does NOT mistake a real Claude payload for grok's", () => { + // The guard that keeps Claude's own enforcement intact. + expect( + isGrokEnvelope({ + hook_event_name: "PreToolUse", + session_id: "s", + cwd: "/w", + tool_name: "Bash", + tool_input: { command: "ls" }, + }), + ).toBe(false); + // A payload with neither marker (e.g. a bare Stop) is not grok's either. + expect(isGrokEnvelope({ stop_hook_active: false })).toBe(false); + // camelCase alone is not enough — Copilot's permissionRequest sends + // `toolName`/`sessionId` too, and must keep its own contract. + expect(isGrokEnvelope({ toolName: "bash", sessionId: "s", cwd: "/w" })).toBe(false); + }); + + it("re-routes a grok payload flagged --cli claude onto grok's contract", () => { + // grok scans ~/.claude/settings.json and /.claude/settings.json by + // default, so it runs OUR claude hooks and passes `--cli claude` while + // piping its own payload. Verified live: without this the hook fires, + // every builtin sees undefined, and nothing is enforced. + expect(resolveEffectiveCli("claude", grokPreToolUsePayload())).toBe("grok"); + }); + + it("leaves every other (cli, payload) pair alone", () => { + const claudePayload = { hook_event_name: "PreToolUse", tool_name: "Bash" }; + expect(resolveEffectiveCli("claude", claudePayload)).toBe("claude"); + expect(resolveEffectiveCli("goose", { event: "PreToolUse" })).toBe("goose"); + expect(resolveEffectiveCli("qwen", { hook_event_name: "PreToolUse" })).toBe("qwen"); + // Already-declared grok stays grok. + expect(resolveEffectiveCli("grok", grokPreToolUsePayload())).toBe("grok"); + }); + + it("end-to-end: a grok-shaped .claude hook still resolves a canonical Bash command", () => { + const parsed = grokPreToolUsePayload(); + const cli = resolveEffectiveCli("claude", parsed); + normalizeCliPayload(cli, parsed); + const tool = canonicalizeToolName(parsed.tool_name as string, cli); + const input = canonicalizeToolInput(tool, parsed.tool_input, cli); + expect(tool).toBe("Bash"); + expect((input as Record).command).toBe("echo FPPROBE"); + }); + + it("end-to-end: a grok-shaped .claude hook exposes a read of .env as file_path", () => { + const parsed: Record = { + hookEventName: "pre_tool_use", + workspaceRoot: "/repo", + toolName: "read_file", + toolInput: { target_file: ".env" }, + }; + const cli = resolveEffectiveCli("claude", parsed); + normalizeCliPayload(cli, parsed); + const tool = canonicalizeToolName(parsed.tool_name as string, cli); + const input = canonicalizeToolInput(tool, parsed.tool_input, cli); + expect(tool).toBe("Read"); + // Both halves of the leak fix: without either, block-env-files sees nothing. + expect((input as Record).file_path).toBe(".env"); + }); +}); diff --git a/__tests__/hooks/inert-deny-shapes.test.ts b/__tests__/hooks/inert-deny-shapes.test.ts index 6b3cf8aba..6af2c1720 100644 --- a/__tests__/hooks/inert-deny-shapes.test.ts +++ b/__tests__/hooks/inert-deny-shapes.test.ts @@ -86,4 +86,72 @@ describe("deny shapes the CLI actually reads", () => { expect(stdout.decision).toBe("block"); expect(stdout.reason).not.toContain("MANDATORY ACTION REQUIRED"); }); + + // ── grok ────────────────────────────────────────────────────────────────── + // Verified live against grok 1.0.3 by A/B on one hook: emitting Claude's + // hookSpecificOutput shape let `echo` run; emitting {decision:"deny"} blocked + // it, overriding --yolo. This is the shape that made the difference. + it("grok PreToolUse uses {decision:'deny'}, NOT Claude's hookSpecificOutput", async () => { + const { result, stdout } = await denyOn("grok", "PreToolUse", { tool_name: "Bash" }); + expect(result.exitCode).toBe(0); + expect(stdout.decision).toBe("deny"); + expect(typeof stdout.reason).toBe("string"); + // The shape grok ignores must NOT be what we send. + expect(stdout.hookSpecificOutput).toBeUndefined(); + }); + + it("grok Stop forces a retry on a real turn end", async () => { + const { stdout } = await denyOn("grok", "Stop", { reason: "end_turn" }); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).toContain("MANDATORY ACTION REQUIRED"); + }); + + it("grok Stop does NOT block the session-shutdown fire", async () => { + // grok fires Stop a second time at shutdown and discards the decision. + // Emitting a block there would record enforcement that cannot happen, so + // the branch must fall through to allow instead. + const { result } = await denyOn("grok", "Stop", { reason: "shutdown" }); + expect(result.decision).toBe("allow"); + expect(result.stdout).toBe(""); + }); + + it("grok Stop still blocks when no reason is present", async () => { + // Fail toward enforcement: an unlabelled Stop is treated as a real one. + const { stdout } = await denyOn("grok", "Stop"); + expect(stdout.decision).toBe("block"); + }); + + // ── qwen ────────────────────────────────────────────────────────────────── + it("qwen PreToolUse keeps Claude's permissionDecision shape", async () => { + // qwen honors Claude's own PreToolUse contract (verified live — it beat + // -y), so it must fall through to the generic branch, not grow a copy. + const { stdout } = await denyOn("qwen", "PreToolUse", { tool_name: "Bash" }); + expect(stdout.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(typeof stdout.hookSpecificOutput.permissionDecisionReason).toBe("string"); + }); + + it("qwen TaskCreated/TaskCompleted deny with the top-level block shape", async () => { + // qwen's todo hooks are the only events in the widened set that can veto: + // in their `validation` phase a {decision:"block"} prevents the write. + // They do NOT read Claude's permissionDecision, so the generic PreToolUse + // branch would have been silently inert here. + for (const ev of ["TaskCreated", "TaskCompleted"]) { + const { result, stdout } = await denyOn("qwen", ev, { phase: "validation" }); + expect(result.exitCode, ev).toBe(0); + expect(stdout.decision, ev).toBe("block"); + expect(typeof stdout.reason, ev).toBe("string"); + expect(stdout.hookSpecificOutput, ev).toBeUndefined(); + // Plain blocked message here — the MANDATORY-ACTION wording is Stop's. + expect(stdout.reason, ev).not.toContain("MANDATORY ACTION REQUIRED"); + } + }); + + it("qwen Stop uses the top-level block shape, not permissionDecision", async () => { + const { stdout } = await denyOn("qwen", "Stop", { stop_hook_active: true }); + expect(stdout.decision).toBe("block"); + expect(stdout.reason).toContain("MANDATORY ACTION REQUIRED"); + // Note stop_hook_active is true on qwen's FIRST fire, so it must not be + // used as an "already retrying" guard here. + expect(stdout.hookSpecificOutput).toBeUndefined(); + }); }); diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 1559a6bab..6767a6cea 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -179,9 +179,9 @@ describe("hooks/install-prompt", () => { "install", ); - // 1 aggregate "all" + 2 detected + 10 undetected - expect(options).toHaveLength(13); - expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + // 1 aggregate "all" + 2 detected + 14 undetected + expect(options).toHaveLength(17); + expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); expect(options[0].label).toBe("Install for all 2 detected"); @@ -206,6 +206,10 @@ describe("hooks/install-prompt", () => { "Devin CLI", "Antigravity CLI", "Goose", + "grok CLI", + "Qwen Code", + "Ori", + "Cline", ]); }); @@ -222,16 +226,16 @@ describe("hooks/install-prompt", () => { expect(options.every((o) => o.detected)).toBe(true); }); - it("install with all 12 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { + it("install with all 16 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( - ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"], + ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"], "install", ); expect(undetected).toEqual([]); - expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options).toHaveLength(17); // aggregate + 16 detected + expect(options[0].label).toBe("Install for all 16 detected"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index d1aac9728..f5b1800aa 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -46,6 +46,7 @@ import { FACTORY_HOOK_EVENT_TYPES, DEVIN_HOOK_EVENT_TYPES, ANTIGRAVITY_HOOK_EVENT_TYPES, + INTEGRATION_TYPES, GOOSE_HOOK_EVENT_TYPES, HOOK_EVENT_TYPES, CLAUDE_INSTALL_EVENT_TYPES, @@ -82,7 +83,7 @@ afterEach(() => { describe("integrations registry", () => { it("listIntegrations returns claude, codex, copilot, cursor, opencode, pi, hermes, and openclaw in declared order", () => { const ids = listIntegrations().map((i) => i.id); - expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"]); }); it("getIntegration('claude') returns claudeCode", () => { @@ -1799,3 +1800,18 @@ describe("claudeCode — WorktreeCreate is never registered", () => { }); }); + +describe("bin/failproofai.mjs --cli validation", () => { + it("accepts exactly the CLIs in INTEGRATION_TYPES", () => { + // This list lived as THREE hardcoded copies inside bin/failproofai.mjs and + // drifted: grok and qwen reached INTEGRATION_TYPES and the `--hook --cli` + // validation, but not the install parser, so `policies --install --cli grok` + // failed outright while the whole unit suite stayed green. It is one list + // now; this asserts it cannot drift again. + const src = readFileSync(resolve(process.cwd(), "bin/failproofai.mjs"), "utf8"); + const m = /const INSTALLABLE_CLIS = \[([^\]]+)\]/.exec(src); + expect(m, "INSTALLABLE_CLIS not found in bin/failproofai.mjs").toBeTruthy(); + const declared = m![1].split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean); + expect(declared.sort()).toEqual([...INTEGRATION_TYPES].sort()); + }); +}); diff --git a/__tests__/hooks/ori-canonicalize.test.ts b/__tests__/hooks/ori-canonicalize.test.ts new file mode 100644 index 000000000..ae076aad9 --- /dev/null +++ b/__tests__/hooks/ori-canonicalize.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment node +/** + * Ori's wire contract, pinned against what was captured off a LIVE agent run. + * + * Every expectation below is a transcription of a real approval-gate payload + * from ori 0.12.0+68f9a36 driving nvidia/nemotron-3.5-lightning:free — not a + * guess from the SDK's type declarations. That distinction is the point: the + * `edit` case in particular could not have been derived from types, because the + * path is inside a patch blob rather than in a field. + */ +import { describe, it, expect } from "vitest"; +import { + canonicalizeToolName, + canonicalizeToolInput, + oriPatchFilePaths, +} from "@/src/hooks/tool-name-canonicalize"; +import { evaluatePolicies } from "@/src/hooks/policy-evaluator"; +import { registerPolicy, clearPolicies } from "@/src/hooks/policy-registry"; +import { deny, instruct } from "@/src/hooks/policy-helpers"; +import type { SessionMetadata } from "@/src/hooks/types"; + +const oriSession: SessionMetadata = { + sessionId: "t", + transcriptPath: "/dev/null", + cwd: "/tmp", + cli: "ori", +}; + +describe("ori tool-name canonicalization", () => { + // All six were observed at the live gate; ori delivers them lowercase. + it.each([ + ["bash", "Bash"], + ["read", "Read"], + ["write", "Write"], + ["edit", "Edit"], + ["glob", "Glob"], + ["grep", "Grep"], + ])("maps %s → %s", (raw, canonical) => { + expect(canonicalizeToolName(raw, "ori")).toBe(canonical); + }); + + it("passes an unknown tool through unchanged so it still reaches the audit", () => { + expect(canonicalizeToolName("some_future_tool", "ori")).toBe("some_future_tool"); + }); +}); + +describe("ori tool-input canonicalization", () => { + it("maps read's `path` onto file_path so path builtins fire", () => { + expect(canonicalizeToolInput("Read", { path: ".env" }, "ori")).toEqual({ file_path: ".env" }); + }); + + it("maps write's `path` and leaves `content` alone", () => { + expect(canonicalizeToolInput("Write", { path: "a.txt", content: "x" }, "ori")).toEqual({ + file_path: "a.txt", + content: "x", + }); + }); + + it("leaves bash/glob/grep untouched — they already arrive canonical", () => { + expect(canonicalizeToolInput("Bash", { command: "ls" }, "ori")).toEqual({ command: "ls" }); + expect(canonicalizeToolInput("Glob", { pattern: "*" }, "ori")).toEqual({ pattern: "*" }); + expect(canonicalizeToolInput("Grep", { pattern: "a", path: "f" }, "ori")).toEqual({ + pattern: "a", + path: "f", + }); + }); +}); + +describe("ori edit patches", () => { + // Captured verbatim from the live gate. + const LIVE_PATCH = [ + "*** Begin Patch", + "*** Update File: data.txt", + "@@", + '-["text":"alpha', + '+["text":"omega', + "*** End Patch", + ].join("\n"); + + it("recovers the path from a real captured patch", () => { + expect(oriPatchFilePaths(LIVE_PATCH)).toEqual(["data.txt"]); + }); + + it("recovers Add / Delete / Move paths too", () => { + const patch = [ + "*** Begin Patch", + "*** Add File: new.ts", + "*** Delete File: old.ts", + "*** Move to: moved.ts", + "*** End Patch", + ].join("\n"); + expect(oriPatchFilePaths(patch)).toEqual(["new.ts", "old.ts", "moved.ts"]); + }); + + it("returns nothing for a patch with no file header", () => { + expect(oriPatchFilePaths("*** Begin Patch\n*** End Patch")).toEqual([]); + }); + + it("derives file_path from the patch so path builtins fire on an edit", () => { + // Without this, `edit` carries no path at all and block-env-files, + // block-secrets-write and every other path builtin silently no-op. + const patch = "*** Begin Patch\n*** Update File: .env\n@@\n-a\n+b\n*** End Patch"; + const out = canonicalizeToolInput("Edit", { patch }, "ori") as Record; + expect(out.file_path).toBe(".env"); + expect(out.ori_patch_files).toEqual([".env"]); + expect(out.patch).toBe(patch); + }); + + it("KNOWN GAP: a multi-file patch exposes only the first path as file_path", () => { + // Asserted so the limitation is visible and cannot rot into a silent + // surprise. The full list stays available to custom policies. + const patch = [ + "*** Begin Patch", + "*** Update File: safe.ts", + "*** Update File: .env", + "*** End Patch", + ].join("\n"); + const out = canonicalizeToolInput("Edit", { patch }, "ori") as Record; + expect(out.file_path).toBe("safe.ts"); + expect(out.ori_patch_files).toEqual(["safe.ts", ".env"]); + }); + + it("leaves a non-string patch alone rather than throwing", () => { + expect(canonicalizeToolInput("Edit", { patch: 42 }, "ori")).toEqual({ patch: 42 }); + }); +}); + +describe("ori verdict shapes", () => { + it("deny emits the flat {permission:'deny'} the ori feature parses", async () => { + clearPolicies(); + registerPolicy("test/blocker", "blocks", async () => deny("nope"), {}, 0); + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, oriSession); + expect(result.exitCode).toBe(0); + const stdout = JSON.parse(result.stdout as string); + expect(stdout.permission).toBe("deny"); + expect(typeof stdout.reason).toBe("string"); + expect(stdout.reason.length).toBeGreaterThan(0); + }); + + it("instruct degrades to allow + a stderr note — ori has no context channel", async () => { + clearPolicies(); + registerPolicy("test/instructor", "instructs", async () => instruct("do the thing"), {}, 0); + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, oriSession); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("do the thing"); + }); +}); diff --git a/__tests__/lib/cli-registry.test.ts b/__tests__/lib/cli-registry.test.ts index 238043dde..cab7df9ef 100644 --- a/__tests__/lib/cli-registry.test.ts +++ b/__tests__/lib/cli-registry.test.ts @@ -12,7 +12,7 @@ import { describe("lib/cli-registry", () => { it("KNOWN_CLI_IDS lists all supported CLIs in stable order", () => { - expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"]); }); it("getCliEntry returns the entry for known ids and undefined for unknown", () => { @@ -73,7 +73,7 @@ describe("lib/cli-registry", () => { it("listExternalCliEntries excludes claude", () => { const ids = listExternalCliEntries().map((c) => c.id); - expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"]); }); it("each CLI has a unique badgeClasses string", () => { diff --git a/__tests__/lib/cline-sessions.test.ts b/__tests__/lib/cline-sessions.test.ts new file mode 100644 index 000000000..af8a85500 --- /dev/null +++ b/__tests__/lib/cline-sessions.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +/** + * Cline's transcript parser, against the real stored shape. + * + * The fixture is a trimmed transcription of an actual + * `.messages.json` read off this machine (cline v3.0.60): same roles, same + * block types, same `call_…` id convention pairing `tool_use` with + * `tool_result`. Cline already speaks Claude's content blocks, so the only real + * work is that pairing — and NOT emitting a phantom user turn for the + * `role:"user"` message that merely carries the results. + */ +import { describe, it, expect } from "vitest"; +import { clineMessagesToLogEntries, clineTimestampToMs } from "@/lib/cline-sessions"; +import type { AssistantEntry, UserEntry, ToolUseBlock } from "@/lib/log-entries"; + +const TS_MS = Date.parse("2026-09-01T09:01:11.792Z"); + +const MESSAGES = [ + { role: "user", content: "read data.txt and search it for alpha" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I will read the file first." }, + { type: "text", text: "Reading now." }, + { type: "tool_use", id: "call_aaa", name: "read_files", input: { files: [{ path: "data.txt" }] } }, + { type: "tool_use", id: "call_bbb", name: "search_codebase", input: { queries: ["alpha"] } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_aaa", name: "read_files", content: [{ text: "1 | alpha" }] }, + { type: "tool_result", tool_use_id: "call_bbb", name: "search_codebase", content: [{ text: "data.txt:1:alpha" }] }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "Found alpha on line 1." }] }, +]; + +describe("clineMessagesToLogEntries", () => { + const entries = clineMessagesToLogEntries(MESSAGES, "1788253271772_bn188", TS_MS); + + it("does not emit a phantom user turn for the tool-result carrier", () => { + // Cline puts tool results in a role:"user" message, Claude-style. Emitting + // that as user prose would put a blank turn in the audit for every tool call. + const users = entries.filter((e) => e.type === "user"); + expect(users).toHaveLength(1); + expect((users[0] as UserEntry).message.content).toBe("read data.txt and search it for alpha"); + }); + + it("keeps thinking, text and tool_use blocks in order", () => { + const a = entries.find((e) => e.type === "assistant") as AssistantEntry; + expect(a.message.content.map((b) => b.type)).toEqual(["thinking", "text", "tool_use", "tool_use"]); + }); + + it("pairs each tool_result back onto its tool_use by id", () => { + const a = entries.find((e) => e.type === "assistant") as AssistantEntry; + const tools = a.message.content.filter((b) => b.type === "tool_use") as ToolUseBlock[]; + expect(tools[0]).toMatchObject({ name: "read_files" }); + expect(tools[0].result?.content).toBe("1 | alpha"); + expect(tools[1]).toMatchObject({ name: "search_codebase" }); + expect(tools[1].result?.content).toBe("data.txt:1:alpha"); + }); + + it("gives every entry the session timestamp — cline stores no per-message time", () => { + // Documented limitation, asserted so it is visible rather than surprising. + expect(new Set(entries.map((e) => e.timestampMs))).toEqual(new Set([TS_MS])); + }); + + it("drops an orphan tool_result instead of throwing", () => { + const orphan = [ + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_zzz", name: "read_files", content: [] }] }, + ]; + expect(clineMessagesToLogEntries(orphan, "s", TS_MS)).toEqual([]); + }); + + it("survives empty and malformed message lists", () => { + expect(clineMessagesToLogEntries([], "s", TS_MS)).toEqual([]); + expect(clineMessagesToLogEntries([{ role: "assistant" }], "s", TS_MS)).toEqual([]); + expect(clineMessagesToLogEntries([{ role: "tool" }], "s", TS_MS)).toEqual([]); + }); + + it("flattens a tool_result whose content is a plain string", () => { + const msgs = [ + { role: "assistant", content: [{ type: "tool_use", id: "c1", name: "run_commands", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "c1", name: "run_commands", content: "ok" }] }, + ]; + const out = clineMessagesToLogEntries(msgs, "s", TS_MS); + const a = out.find((e) => e.type === "assistant") as AssistantEntry; + expect((a.message.content[0] as ToolUseBlock).result?.content).toBe("ok"); + }); +}); + +describe("clineTimestampToMs", () => { + it("parses cline's ISO-8601 strings", () => { + expect(clineTimestampToMs("2026-09-01T09:01:11.792Z")).toBe(TS_MS); + }); + it("returns 0 for absent or unparseable values", () => { + expect(clineTimestampToMs(null)).toBe(0); + expect(clineTimestampToMs("nope")).toBe(0); + }); +}); diff --git a/__tests__/lib/grok-qwen-sessions.test.ts b/__tests__/lib/grok-qwen-sessions.test.ts new file mode 100644 index 000000000..6fcc7b2c1 --- /dev/null +++ b/__tests__/lib/grok-qwen-sessions.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment node +// +// Transcript parsers for the two newest CLIs. Every fixture line below is a +// trimmed copy of a REAL capture — grok 1.0.3 `chat_history.jsonl` and +// qwen-code 0.21.12 `chats/.jsonl` — so these lock the shapes that were +// actually observed, not the shapes the vendors document. +import { describe, it, expect } from "vitest"; +import { grokLinesToLogEntries, decodeGrokProjectDir } from "@/lib/grok-sessions"; +import { qwenLinesToLogEntries } from "@/lib/qwen-sessions"; +import type { AssistantEntry, ToolUseBlock } from "@/lib/log-entries"; + +function toolUses(entries: ReturnType): ToolUseBlock[] { + return entries + .filter((e): e is AssistantEntry => e.type === "assistant") + .flatMap((e) => e.message.content) + .filter((b): b is ToolUseBlock => typeof b === "object" && b.type === "tool_use"); +} + +describe("grok transcript parsing", () => { + // Verbatim shapes from a real session directory. + const LINES: Record[] = [ + { type: "system", content: "You are Grok 4.5 released by xAI…" }, + // Environment preamble: a `user` line with NO prompt_index. + { type: "user", content: [{ type: "text", text: "OS: linux" }] }, + // An injected reminder — also a `user` line, but synthetic. + { + type: "user", + synthetic_reason: "skills", + content: [{ type: "text", text: "" }], + }, + // The only line the operator actually typed. + { + type: "user", + prompt_index: 0, + content: [{ type: "text", text: "run echo FPPROBE" }], + }, + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "thinking…" }] }, + { + type: "assistant", + content: "I'll run `echo FPPROBE`.", + model_id: "grok-4.5", + tool_calls: [ + { + id: "call-1", + name: "run_terminal_command", + // grok serializes arguments as a JSON STRING, not an object. + arguments: '{"command":"echo FPPROBE","description":"Echo"}', + }, + ], + }, + { type: "tool_result", tool_call_id: "call-1", content: "exit: 0\nFPPROBE\n" }, + ]; + + it("keeps only the operator's real prompt as a user turn", () => { + const entries = grokLinesToLogEntries(LINES, 1_700_000_000_000); + const users = entries.filter((e) => e.type === "user"); + expect(users).toHaveLength(1); + expect((users[0] as { message: { content: string } }).message.content).toContain("echo FPPROBE"); + }); + + it("skips the system prompt and the model's private reasoning", () => { + const entries = grokLinesToLogEntries(LINES, 1_700_000_000_000); + // system-prompt and reasoning lines must not surface as turns. + expect(entries.filter((e) => e.type === "system")).toHaveLength(0); + }); + + it("parses tool_calls[].arguments from its JSON string into a real object", () => { + const calls = toolUses(grokLinesToLogEntries(LINES, 1_700_000_000_000)); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("run_terminal_command"); + expect(calls[0].input).toEqual({ command: "echo FPPROBE", description: "Echo" }); + }); + + it("pairs tool_result back onto its call by tool_call_id", () => { + const calls = toolUses(grokLinesToLogEntries(LINES, 1_700_000_000_000)); + expect(calls[0].result?.content).toBe("exit: 0\nFPPROBE\n"); + }); + + it("anchors the synthesized timeline on the supplied start time", () => { + // chat_history.jsonl carries NO timestamps, so ordering must come from file + // order and the absolute position from summary.json's created_at. + const startMs = 1_700_000_000_000; + const entries = grokLinesToLogEntries(LINES, startMs); + expect(entries[0].timestampMs).toBeGreaterThanOrEqual(startMs); + const times = entries.map((e) => e.timestampMs); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); + + it("keeps malformed tool arguments visible instead of dropping them", () => { + const calls = toolUses( + grokLinesToLogEntries( + [{ type: "assistant", content: "", tool_calls: [{ id: "c", name: "x", arguments: "{not json" }] }], + 1, + ), + ); + expect(calls[0].input).toEqual({ arguments: "{not json" }); + }); + + it("percent-decodes grok's project folder names", () => { + // grok percent-encodes the cwd where every other JSONL store dash-encodes it. + expect(decodeGrokProjectDir("%2Ftmp%2Ffp-probe%2Fws")).toBe("/tmp/fp-probe/ws"); + // A folder that is not valid percent-encoding degrades to the raw name. + expect(decodeGrokProjectDir("%%%")).toBe("%%%"); + }); +}); + +describe("qwen transcript parsing", () => { + // Verbatim shapes: Gemini-style `message.parts`, role "model" for assistant. + const LINES: Record[] = [ + { + type: "user", + uuid: "u1", + sessionId: "s1", + cwd: "/tmp/qws", + timestamp: "2026-08-16T18:30:00.000Z", + message: { role: "user", parts: [{ text: "create report.txt" }] }, + }, + { type: "system", uuid: "sys1", subtype: "info", systemPayload: {}, timestamp: "2026-08-16T18:30:01.000Z" }, + { + type: "assistant", + uuid: "a1", + model: "gpt-5.6-luna", + timestamp: "2026-08-16T18:30:02.000Z", + message: { + role: "model", + parts: [ + { text: "Creating it now." }, + { functionCall: { id: "call_1", name: "write_file", args: { file_path: "/tmp/qws/report.txt", content: "alpha" } } }, + ], + }, + }, + { + type: "tool_result", + uuid: "t1", + timestamp: "2026-08-16T18:30:03.000Z", + message: { + role: "user", + parts: [{ functionResponse: { id: "call_1", name: "write_file", response: { output: "written" } } }], + }, + toolCallResult: { callId: "call_1", status: "success", resultDisplay: "Wrote 5 bytes" }, + }, + ]; + + it("reads Gemini-shaped parts, treating role 'model' as the assistant", () => { + const entries = qwenLinesToLogEntries(LINES); + const assistants = entries.filter((e) => e.type === "assistant"); + expect(assistants).toHaveLength(1); + expect((assistants[0] as AssistantEntry).message.model).toBe("gpt-5.6-luna"); + }); + + it("turns functionCall parts into tool_use blocks with canonical-key args", () => { + const calls = toolUses(qwenLinesToLogEntries(LINES)); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("write_file"); + // qwen's tool args are already canonical — no input map needed anywhere. + expect(calls[0].input).toEqual({ file_path: "/tmp/qws/report.txt", content: "alpha" }); + }); + + it("prefers the toolCallResult sidecar's rendered text for the result", () => { + const calls = toolUses(qwenLinesToLogEntries(LINES)); + // resultDisplay is what the TUI showed, so it beats the raw response blob. + expect(calls[0].result?.content).toBe("Wrote 5 bytes"); + }); + + it("falls back to the functionResponse payload when no sidecar is present", () => { + const noSidecar = LINES.map((l) => + l.type === "tool_result" ? { ...l, toolCallResult: undefined } : l, + ); + const calls = toolUses(qwenLinesToLogEntries(noSidecar)); + expect(calls[0].result?.content).toBe("written"); + }); + + it("skips system bookkeeping lines", () => { + const entries = qwenLinesToLogEntries(LINES); + expect(entries.filter((e) => e.type === "system")).toHaveLength(0); + }); + + it("keeps the user turn and preserves chronological order", () => { + const entries = qwenLinesToLogEntries(LINES); + const users = entries.filter((e) => e.type === "user"); + expect(users).toHaveLength(1); + const times = entries.map((e) => e.timestampMs); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); +}); + +describe("grok project slugs", () => { + it("derives a URL-safe slug from the cwd, not grok's percent-encoded folder", async () => { + // Regression: the on-disk folder is `%2Ftmp%2Ffp-prod`, which becomes + // `%252F…` once it is a link href, and /project/[name] 404s on it. Every + // grok project was unreachable from the projects list. + const { encodeFolderName } = await import("@/lib/paths"); + const slug = encodeFolderName("/tmp/fp-prod"); + expect(slug).not.toContain("%"); + // Byte-identical to what Claude/Factory/Qwen derive for the same cwd, which + // is what makes those rows MERGE instead of showing up twice. + expect(slug).toBe(encodeFolderName("/tmp/fp-prod")); + expect(slug).toBe("-tmp-fp-prod"); + }); + + it("does not rely on decoding the slug back to a cwd", async () => { + // `decodeFolderName` is lossy whenever the path itself contains a dash — + // `-tmp-fp-prod` decodes to `/tmp/fp/prod`, not `/tmp/fp-prod`. That is the + // whole reason grok's project page takes its cwd from summary.json's + // `info.cwd` and treats the decode as a last resort, exactly as the Claude + // and Factory adapters do with their own headers. + const { decodeFolderName, encodeFolderName } = await import("@/lib/paths"); + expect(decodeFolderName(encodeFolderName("/tmp/fp-prod"))).toBe("/tmp/fp/prod"); + }); +}); diff --git a/__tests__/lib/ori-sessions.test.ts b/__tests__/lib/ori-sessions.test.ts new file mode 100644 index 000000000..3d6303be4 --- /dev/null +++ b/__tests__/lib/ori-sessions.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment node +/** + * ori's transcript parser, against the real serialized shape. + * + * The fixture below is a trimmed transcription of an actual + * `ori_agent_loop_history.prompt` blob read out of a live + * `~/.ori/global/.ori/state.sqlite` (ori 0.12.0+68f9a36) — same roles, same + * block types, same `call-` id convention pairing a `tool-call` with its + * `tool-result`. `oriMessagesToLogEntries` is pure, so none of this needs a DB. + */ +import { describe, it, expect } from "vitest"; +import { oriMessagesToLogEntries, oriTimestampToMs } from "@/lib/ori-sessions"; +import type { AssistantEntry, UserEntry } from "@/lib/log-entries"; + +const TS_MS = Date.parse("2026-08-31T14:19:33.661Z"); + +const MESSAGES = [ + { role: "system", content: "# Ori Coding Agent\nYou are a coding agent…" }, + { role: "user", content: "read data.txt then search it for alpha" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me read the file first." }, + { type: "tool-call", id: "call-aaa", name: "read", params: { path: "data.txt" }, providerExecuted: false }, + { type: "tool-call", id: "call-bbb", name: "grep", params: { pattern: "alpha", path: "data.txt" }, providerExecuted: false }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", id: "call-aaa", name: "read", isFailure: false, result: [{ text: "alpha\nbeta\n", type: "input_text" }] }, + { type: "tool-result", id: "call-bbb", name: "grep", isFailure: false, result: [{ text: "data.txt:1:alpha", type: "input_text" }] }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "Found alpha on line 1." }] }, +]; + +describe("oriMessagesToLogEntries", () => { + const entries = oriMessagesToLogEntries(MESSAGES, "sess-1", TS_MS); + + it("drops the system message — it is ori's static boilerplate, identical every session", () => { + expect(entries.some((e) => e.type === "system")).toBe(false); + expect(entries).toHaveLength(3); // user, assistant(tools), assistant(text) + }); + + it("keeps the user turn", () => { + const user = entries[0] as UserEntry; + expect(user.type).toBe("user"); + expect(user.message.content).toBe("read data.txt then search it for alpha"); + }); + + it("maps reasoning → thinking and tool-call → tool_use", () => { + const a = entries[1] as AssistantEntry; + expect(a.type).toBe("assistant"); + const kinds = a.message.content.map((b) => b.type); + expect(kinds).toEqual(["thinking", "tool_use", "tool_use"]); + }); + + it("pairs each tool-result back onto its tool-call by id", () => { + const a = entries[1] as AssistantEntry; + const tools = a.message.content.filter((b) => b.type === "tool_use"); + expect(tools[0]).toMatchObject({ name: "read", input: { path: "data.txt" } }); + expect(tools[0].result?.content).toBe("alpha\nbeta\n"); + expect(tools[1]).toMatchObject({ name: "grep" }); + expect(tools[1].result?.content).toBe("data.txt:1:alpha"); + }); + + it("gives every entry the session timestamp — ori stores no per-message time", () => { + // Documented limitation, asserted so it is visible rather than surprising. + expect(new Set(entries.map((e) => e.timestampMs))).toEqual(new Set([TS_MS])); + }); + + it("drops a tool-result with no matching call instead of throwing", () => { + const orphan = [{ role: "tool", content: [{ type: "tool-result", id: "call-zzz", name: "read", result: [] }] }]; + expect(oriMessagesToLogEntries(orphan, "s", TS_MS)).toEqual([]); + }); + + it("survives an empty or malformed message list", () => { + expect(oriMessagesToLogEntries([], "s", TS_MS)).toEqual([]); + expect(oriMessagesToLogEntries([{ role: "assistant" }], "s", TS_MS)).toEqual([]); + }); +}); + +describe("oriTimestampToMs", () => { + it("parses ori's ISO-8601 strings", () => { + expect(oriTimestampToMs("2026-08-31T14:19:33.661Z")).toBe(TS_MS); + }); + it("returns 0 for absent or unparseable values", () => { + expect(oriTimestampToMs(null)).toBe(0); + expect(oriTimestampToMs("not a date")).toBe(0); + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index 427e6bd68..b479843d7 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -49,6 +49,21 @@ vi.mock("@/lib/hermes-projects", () => ({ // Antigravity reads the real ~/.gemini/antigravity-cli/brain dir; mock it to [] // so a developer's local Antigravity sessions don't leak into these assertions. +// grok and qwen read real session trees with `readdirSync` (which the +// `fs/promises` mock above does not cover), so a developer machine that has +// used either CLI would leak its own projects into these assertions. +vi.mock("@/lib/grok-projects", () => ({ + getGrokProjects: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/qwen-projects", () => ({ + getQwenProjects: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/ori-projects", () => ({ + getOriProjects: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/cline-projects", () => ({ + getClineProjects: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/antigravity-projects", () => ({ getAntigravityProjects: vi.fn(async () => []), })); diff --git a/__tests__/lib/session-stores.test.ts b/__tests__/lib/session-stores.test.ts new file mode 100644 index 000000000..e767af514 --- /dev/null +++ b/__tests__/lib/session-stores.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +/** + * The session-store table must cover every integration. + * + * This guards a bug that shipped: the session page resolved a session by walking + * a TWELVE-level nested if/else of `getCachedXSessionLog` calls, whose innermost + * `else` set "Session log file not found." while `cli` still held its default of + * `"claude"`. Four integrations — grok, qwen, ori, cline — were added without + * anyone extending that pyramid, so every one of their sessions rendered with a + * CLAUDE CODE badge, pointed at a Claude transcript that did not exist, and + * showed an empty log. The store was never consulted at all. + * + * Nothing failed loudly, because a missing branch in an if/else chain is not a + * type error and not a runtime error — it is just an `else`. + * + * The chain is now a table, and this asserts the table is complete. `claude` is + * excluded on purpose: it is the primary path the page tries first, not a + * fallback store. + */ +import { describe, it, expect } from "vitest"; +import { EXTERNAL_SESSION_STORES } from "@/lib/session-stores"; +import { INTEGRATION_TYPES } from "@/src/hooks/types"; + +describe("external session stores", () => { + it("covers every integration except claude", () => { + const covered = EXTERNAL_SESSION_STORES.map((s) => s.cli).sort(); + const expected = INTEGRATION_TYPES.filter((c) => c !== "claude").sort(); + expect(covered).toEqual([...expected]); + }); + + it("lists each store exactly once", () => { + const ids = EXTERNAL_SESSION_STORES.map((s) => s.cli); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("gives every store a loader and a human label", () => { + for (const s of EXTERNAL_SESSION_STORES) { + expect(typeof s.load, `${s.cli} needs a loader`).toBe("function"); + expect(s.label.length, `${s.cli} needs a label`).toBeGreaterThan(0); + } + }); + + it("labels match the dashboard registry, so the badge and the header agree", async () => { + // A session page that says "Ori" beside a badge reading something else is + // the kind of mismatch nobody notices until a screenshot. + const { getCliLabel } = await import("@/lib/cli-registry"); + for (const s of EXTERNAL_SESSION_STORES) { + expect(s.label, `${s.cli} label`).toBe(getCliLabel(s.cli)); + } + }); +}); diff --git a/__tests__/scripts/copy-counts.test.ts b/__tests__/scripts/copy-counts.test.ts index c7ad91c6f..d1d71fbfe 100644 --- a/__tests__/scripts/copy-counts.test.ts +++ b/__tests__/scripts/copy-counts.test.ts @@ -59,14 +59,14 @@ describe("copy counts match source", () => { // Fails loudly if someone adds a harness or a policy without re-reading the // copy. Update this block ONLY together with every string it guards. expect(TRUTH).toEqual({ - harnesses: 12, + harnesses: 16, // 39, not 40: `block-self-pause` and `block-failproofai-commands` are one // alwaysOn guard now — an agent that can disable either can disable // enforcement, so they were never two decisions. policies: 39, events: 29, - preToolUseBlocks: 12, - stopBlocks: 8, + preToolUseBlocks: 16, + stopBlocks: 10, }); }); @@ -123,14 +123,20 @@ describe("copy counts match source", () => { }); it("README names every shipped harness, in the class it belongs to", () => { - // The twelve are TWO classes: ten coding CLIs and two gateways. The Python - // SDK is a separate door, not a third class of the twelve — and it reports - // events rather than sitting in the tool-call path, so it observes without - // enforcing. Copy that folds it in overstates what it does, which is the - // single easiest way for a reader to catch us out. + // The sixteen are TWO classes: fourteen coding CLIs and two gateways. The + // Python SDK is a separate door, not a third class of the sixteen — and it + // reports events rather than sitting in the tool-call path, so it observes + // without enforcing. Copy that folds it in overstates what it does, which is + // the single easiest way for a reader to catch us out. + // + // These two words are spelled out rather than derived from + // INTEGRATION_TYPES.length on purpose: the point is that a HUMAN re-read the + // opening paragraph, which is the first support statement anyone sees. A + // computed count would keep this green while the surrounding prose rotted — + // which is exactly what happened between the 12th and 16th integrations. const text = read("README.md"); - expect(text).toMatch(/Twelve harnesses in two classes/); - expect(text).toMatch(/ten coding CLIs/); + expect(text).toMatch(/Sixteen harnesses in two classes/); + expect(text).toMatch(/fourteen coding CLIs/); expect(text).toMatch(/Hermes, OpenClaw/); // The SDK must never be described as carrying policies. const sdkClaim = /Python SDK[^.]*same polic/i; @@ -152,6 +158,10 @@ describe("copy counts match source", () => { devin: "Devin CLI", antigravity: "Antigravity CLI", goose: "Goose", + grok: "grok CLI", + qwen: "Qwen Code", + ori: "Ori", + cline: "Cline", }; // Adding an integration without documenting it is the drift this catches. expect(Object.keys(NAMES).sort()).toEqual([...INTEGRATION_TYPES].sort()); @@ -163,9 +173,9 @@ describe("copy counts match source", () => { // defensible because enforcement-capability.ts carries the probed version on // every row. If a re-probe moves a row, the prose has to move with it. const quickstart = read("docs/start/quickstart.mdx"); - expect(quickstart).toContain("verified on all 12"); - expect(TRUTH.preToolUseBlocks).toBe(12); - expect(quickstart).toMatch(/verified on 8\b/); - expect(TRUTH.stopBlocks).toBe(8); + expect(quickstart).toContain("verified on all 16"); + expect(TRUTH.preToolUseBlocks).toBe(16); + expect(quickstart).toMatch(/verified on 10\b/); + expect(TRUTH.stopBlocks).toBe(10); }); }); diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx index 3478d8084..f78e59a64 100644 --- a/app/project/[name]/page.tsx +++ b/app/project/[name]/page.tsx @@ -12,6 +12,10 @@ import { getCachedFactorySessionsByEncodedName } from "@/lib/factory-projects"; import { getCachedDevinSessionsByEncodedName } from "@/lib/devin-projects"; import { getCachedAntigravitySessionsByEncodedName } from "@/lib/antigravity-projects"; import { getCachedGooseSessionsByEncodedName } from "@/lib/goose-projects"; +import { getCachedGrokSessionsByEncodedName } from "@/lib/grok-projects"; +import { getCachedQwenSessionsByEncodedName } from "@/lib/qwen-projects"; +import { getCachedOriSessionsByEncodedName } from "@/lib/ori-projects"; +import { getCachedClineSessionsByEncodedName } from "@/lib/cline-projects"; import { logWarn } from "@/lib/logger"; import { decodeFolderName, projectDisplayName, isSyntheticProjectPath } from "@/lib/paths"; import { notFound } from "next/navigation"; @@ -50,7 +54,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { } // Note: decodeFolderName is lossy when cwds contain `-` (every `-` becomes `/`), // so each external CLI looks up sessions by re-encoding cwd and matching the slug. - const [codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose] = await Promise.all([ + const [codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen, ori, cline] = await Promise.all([ getCachedCodexSessionsByEncodedName(name), getCachedCopilotSessionsByEncodedName(name), getCachedCursorSessionsByEncodedName(name), @@ -62,6 +66,10 @@ export default async function ProjectPage({ params }: ProjectPageProps) { getCachedDevinSessionsByEncodedName(name), getCachedAntigravitySessionsByEncodedName(name), getCachedGooseSessionsByEncodedName(name), + getCachedGrokSessionsByEncodedName(name), + getCachedQwenSessionsByEncodedName(name), + getCachedOriSessionsByEncodedName(name), + getCachedClineSessionsByEncodedName(name), ]); const codexSessions = codex.sessions; const copilotSessions = copilot.sessions; @@ -74,6 +82,10 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const devinSessions = devin.sessions; const antigravitySessions = antigravity.sessions; const gooseSessions = goose.sessions; + const grokSessions = grok.sessions; + const qwenSessions = qwen.sessions; + const oriSessions = ori.sessions; + const clineSessions = cline.sessions; if ( !claudeExists && @@ -87,7 +99,11 @@ export default async function ProjectPage({ params }: ProjectPageProps) { factorySessions.length === 0 && devinSessions.length === 0 && antigravitySessions.length === 0 && - gooseSessions.length === 0 + gooseSessions.length === 0 && + grokSessions.length === 0 && + qwenSessions.length === 0 && + oriSessions.length === 0 && + clineSessions.length === 0 ) { notFound(); } @@ -96,7 +112,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { // `decodeFolderName(name)` is ambiguous for cwds containing `-` (every `-` // becomes `/`). Each external transcript records the literal cwd, so they // round-trip correctly. First non-null wins (Codex → Copilot → Cursor → OpenCode → Pi). - const canonicalRoot = codex.cwd ?? copilot.cwd ?? cursor.cwd ?? opencode.cwd ?? pi.cwd ?? hermes.cwd ?? openclaw.cwd ?? factory.cwd ?? devin.cwd ?? antigravity.cwd ?? goose.cwd ?? decodedName; + const canonicalRoot = codex.cwd ?? copilot.cwd ?? cursor.cwd ?? opencode.cwd ?? pi.cwd ?? hermes.cwd ?? openclaw.cwd ?? factory.cwd ?? devin.cwd ?? antigravity.cwd ?? goose.cwd ?? grok.cwd ?? qwen.cwd ?? decodedName; // Project header metadata let lastModified: Date | null = null; @@ -110,7 +126,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) { logWarn(`Failed to get stats for project ${decodedName}:`, error); } } - const newestExternal = [codexSessions[0], copilotSessions[0], cursorSessions[0], opencodeSessions[0], piSessions[0], hermesSessions[0], openclawSessions[0], factorySessions[0], devinSessions[0], antigravitySessions[0], gooseSessions[0]] + const newestExternal = [codexSessions[0], copilotSessions[0], cursorSessions[0], opencodeSessions[0], piSessions[0], hermesSessions[0], openclawSessions[0], factorySessions[0], devinSessions[0], antigravitySessions[0], gooseSessions[0], grokSessions[0], qwenSessions[0], oriSessions[0], clineSessions[0]] .filter((s): s is SessionFile => !!s) .map((s) => s.lastModified) .reduce((acc, d) => (!acc || d.getTime() > acc.getTime() ? d : acc), null); @@ -132,6 +148,10 @@ export default async function ProjectPage({ params }: ProjectPageProps) { ...devinSessions, ...antigravitySessions, ...gooseSessions, + ...grokSessions, + ...qwenSessions, + ...oriSessions, + ...clineSessions, ].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); // Path line: prefer the Claude storage dir if present (matches existing UX); diff --git a/app/project/[name]/session/[sessionId]/page.tsx b/app/project/[name]/session/[sessionId]/page.tsx index 14f878273..5e67f480a 100644 --- a/app/project/[name]/session/[sessionId]/page.tsx +++ b/app/project/[name]/session/[sessionId]/page.tsx @@ -3,17 +3,7 @@ import Link from "next/link"; import { ArrowLeft, Download } from "lucide-react"; import { notFound } from "next/navigation"; import { getCachedSessionLog, type LogEntry } from "@/lib/log-entries"; -import { getCachedCodexSessionLog } from "@/lib/codex-sessions"; -import { getCachedCopilotSessionLog } from "@/lib/copilot-sessions"; -import { getCachedCursorSessionLog } from "@/lib/cursor-sessions"; -import { getCachedOpenCodeSessionLog } from "@/lib/opencode-sessions"; -import { getCachedPiSessionLog } from "@/lib/pi-sessions"; -import { getCachedHermesSessionLog } from "@/lib/hermes-sessions"; -import { getCachedOpenClawSessionLog } from "@/lib/openclaw-sessions"; -import { getCachedFactorySessionLog } from "@/lib/factory-sessions"; -import { getCachedDevinSessionLog } from "@/lib/devin-sessions"; -import { getCachedAntigravitySessionLog } from "@/lib/antigravity-sessions"; -import { getCachedGooseSessionLog } from "@/lib/goose-sessions"; +import { EXTERNAL_SESSION_STORES, type SessionCli } from "@/lib/session-stores"; import { decodeFolderName } from "@/lib/paths"; import { baseSessionId } from "@/lib/utils/session-id"; import { resolveProjectPath, UUID_RE } from "@/lib/projects"; @@ -57,8 +47,9 @@ export default async function SessionPage({ params }: SessionPageProps) { let entries: LogEntry[] | null = null; let rawLines: Record[] | null = null; let error: string | null = null; - let cli: "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose" = "claude"; + let cli: SessionCli = "claude"; let externalCwd: string | undefined; + let cliLabel: string | undefined; try { // Use raw folder name for file operations — decodedName is for display only @@ -68,98 +59,19 @@ export default async function SessionPage({ params }: SessionPageProps) { } catch (e) { const isNotFound = (e as NodeJS.ErrnoException).code === "ENOENT"; if (isNotFound) { - // Fall back through external stores in order: Codex → Copilot → Cursor → OpenCode → Pi. - // Each store keys by sessionId rather than the project slug, so the - // [name] segment is irrelevant on these branches. - const codex = await getCachedCodexSessionLog(decodedSessionId); - if (codex) { - entries = codex.entries; - rawLines = codex.rawLines; - externalCwd = codex.cwd; - cli = "codex"; - } else { - const copilot = await getCachedCopilotSessionLog(decodedSessionId); - if (copilot) { - entries = copilot.entries; - rawLines = copilot.rawLines; - externalCwd = copilot.cwd; - cli = "copilot"; - } else { - const cursor = await getCachedCursorSessionLog(decodedSessionId); - if (cursor) { - entries = cursor.entries; - rawLines = cursor.rawLines; - externalCwd = cursor.cwd; - cli = "cursor"; - } else { - const opencode = await getCachedOpenCodeSessionLog(decodedSessionId); - if (opencode) { - entries = opencode.entries; - rawLines = opencode.rawLines; - externalCwd = opencode.cwd; - cli = "opencode"; - } else { - const pi = await getCachedPiSessionLog(decodedSessionId); - if (pi) { - entries = pi.entries; - rawLines = pi.rawLines; - externalCwd = pi.cwd; - cli = "pi"; - } else { - const hermes = await getCachedHermesSessionLog(decodedSessionId); - if (hermes) { - entries = hermes.entries; - rawLines = hermes.rawLines; - externalCwd = hermes.cwd; - cli = "hermes"; - } else { - const openclaw = await getCachedOpenClawSessionLog(decodedSessionId); - if (openclaw) { - entries = openclaw.entries; - rawLines = openclaw.rawLines; - externalCwd = openclaw.cwd; - cli = "openclaw"; - } else { - const factory = await getCachedFactorySessionLog(decodedSessionId); - if (factory) { - entries = factory.entries; - rawLines = factory.rawLines; - externalCwd = factory.cwd; - cli = "factory"; - } else { - const devin = await getCachedDevinSessionLog(decodedSessionId); - if (devin) { - entries = devin.entries; - rawLines = devin.rawLines; - externalCwd = devin.cwd; - cli = "devin"; - } else { - const antigravity = await getCachedAntigravitySessionLog(decodedSessionId); - if (antigravity) { - entries = antigravity.entries; - rawLines = antigravity.rawLines; - externalCwd = antigravity.cwd; - cli = "antigravity"; - } else { - const goose = await getCachedGooseSessionLog(decodedSessionId); - if (goose) { - entries = goose.entries; - rawLines = goose.rawLines; - externalCwd = goose.cwd; - cli = "goose"; - } else { - error = "Session log file not found."; - } - } - } - } - } - } - } - } - } - } + // Not a Claude session — walk the external stores. Each keys by sessionId + // rather than the project slug, so the [name] segment is irrelevant here. + for (const store of EXTERNAL_SESSION_STORES) { + const found = await store.load(decodedSessionId); + if (!found) continue; + entries = found.entries; + rawLines = found.rawLines ?? null; + externalCwd = found.cwd; + cli = store.cli; + cliLabel = store.label; + break; } + if (!entries) error = "Session log file not found."; } else { error = "Failed to read session log."; } @@ -167,30 +79,9 @@ export default async function SessionPage({ params }: SessionPageProps) { const isExternal = cli !== "claude"; const headerLabel = isExternal ? "CLI" : "Project"; - const headerValue = - cli === "codex" - ? `OpenAI Codex${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "copilot" - ? `GitHub Copilot${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "cursor" - ? `Cursor Agent${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "opencode" - ? `OpenCode${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "pi" - ? `Pi${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "hermes" - ? `Hermes${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "openclaw" - ? `OpenClaw${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "factory" - ? `Factory Droid${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "devin" - ? `Devin CLI${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "antigravity" - ? `Antigravity CLI${externalCwd ? ` · ${externalCwd}` : ""}` - : cli === "goose" - ? `Goose${externalCwd ? ` · ${externalCwd}` : ""}` - : decodedName; + const headerValue = cliLabel + ? `${cliLabel}${externalCwd ? ` · ${externalCwd}` : ""}` + : decodedName; return (

diff --git a/assets/logos/cline.png b/assets/logos/cline.png new file mode 100644 index 000000000..db6f1d8fd Binary files /dev/null and b/assets/logos/cline.png differ diff --git a/assets/logos/grok-dark.svg b/assets/logos/grok-dark.svg new file mode 100644 index 000000000..cb1537fd2 --- /dev/null +++ b/assets/logos/grok-dark.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/assets/logos/grok-light.svg b/assets/logos/grok-light.svg new file mode 100644 index 000000000..a813f6c5c --- /dev/null +++ b/assets/logos/grok-light.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/assets/logos/ori-dark.svg b/assets/logos/ori-dark.svg new file mode 100644 index 000000000..295b33638 --- /dev/null +++ b/assets/logos/ori-dark.svg @@ -0,0 +1 @@ +Oriori diff --git a/assets/logos/ori-light.svg b/assets/logos/ori-light.svg new file mode 100644 index 000000000..b8043a60f --- /dev/null +++ b/assets/logos/ori-light.svg @@ -0,0 +1 @@ +Oriori diff --git a/assets/logos/qwen.svg b/assets/logos/qwen.svg new file mode 100644 index 000000000..f2d0ada16 --- /dev/null +++ b/assets/logos/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 58415f53a..4a2d4ae47 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -76,6 +76,19 @@ let lastSubcommand = null; // with the right event name. Mirrors the cli_install_failure / cli_uninstall_failure // pattern below for parity. Cleared back to null after the success track. let lastPolicyAction = null; +/** + * Every CLI `policies --install/--uninstall/list --cli` accepts. + * + * ONE list, referenced from all three flag parsers below. It used to be three + * hardcoded copies, and they drifted: grok and qwen were added to the `--hook + * --cli` validation and to INTEGRATION_TYPES, but not here, so a real + * `policies --install --cli grok` was rejected outright while every unit test + * passed. __tests__/hooks/integrations.test.ts asserts this equals + * INTEGRATION_TYPES so the next CLI cannot repeat it. + */ +const INSTALLABLE_CLIS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"]; +const VALID_CLIS_USAGE = `Missing value(s) for --cli. Usage: --cli ${INSTALLABLE_CLIS.join(" ")} (or any subset)`; + async function track(name, props) { try { if (!_telemetry) { @@ -121,7 +134,7 @@ const hookIdx = args.indexOf("--hook"); if (hookIdx >= 0) { if (!args[hookIdx + 1]) { console.error("Error: Missing event type after --hook"); - console.error("Usage: failproofai --hook [--cli ]"); + console.error("Usage: failproofai --hook [--cli ]"); process.exit(1); } const eventType = args[hookIdx + 1]; @@ -143,6 +156,10 @@ if (hookIdx >= 0) { || cliArg === "devin" || cliArg === "antigravity" || cliArg === "goose" + || cliArg === "grok" + || cliArg === "qwen" + || cliArg === "ori" + || cliArg === "cline" ) ? cliArg : "claude"; @@ -408,7 +425,7 @@ async function runCli() { label: "options", entries: [ ["--hook ", "PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd, PreCompact, Notification, PermissionRequest"], - ["--cli ", "claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose. Defaults to claude. It selects which payload shape to expect: each CLI names its events and tool arguments differently, and failproofai canonicalizes them."], + ["--cli ", "claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, ori. Defaults to claude. It selects which payload shape to expect: each CLI names its events and tool arguments differently, and failproofai canonicalizes them."], ], }, { @@ -1669,7 +1686,7 @@ async function runCli() { } // --cli accepts one or more space-separated values, optionally repeated. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1871,7 +1888,7 @@ async function runCli() { // --cli claude codex copilot // --cli claude --cli codex // Values are consumed greedily until the next flag or end of argv. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1888,7 +1905,7 @@ async function runCli() { consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi hermes openclaw (or any subset)"); + throw new CliError(VALID_CLIS_USAGE); } } @@ -1960,7 +1977,7 @@ async function runCli() { } // --cli accepts one or more space-separated values; same parser as install. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + const VALID_CLIS = new Set(INSTALLABLE_CLIS); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -1977,7 +1994,7 @@ async function runCli() { consumed++; } if (consumed === 0) { - throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi hermes openclaw (or any subset)"); + throw new CliError(VALID_CLIS_USAGE); } } diff --git a/bun.lock b/bun.lock index 3312d1692..510efbdb9 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ }, "overrides": { "brace-expansion": "5.0.9", + "browserslist": "4.28.8", "eslint-plugin-react-hooks": "7.0.1", "nanoid": "3.3.18", "postcss": "8.5.26", @@ -493,7 +494,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -567,7 +568,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.363", "", {}, "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.420", "", {}, "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -981,7 +982,7 @@ "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1235,7 +1236,7 @@ "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1317,6 +1318,10 @@ "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="], + + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 0f88e4965..00321f282 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -757,6 +757,8 @@ const HARNESS_KEYS: &[&str] = &[ "opencode", "devin", "hermes", + "grok", + "qwen", ]; fn collector_tasks() -> Vec { @@ -954,7 +956,7 @@ fn collector_tasks() -> Vec { }; use fpai_collect::sources::{ - antigravity, claude, codex, copilot, cursor, factory, openclaw, pi, + antigravity, claude, codex, copilot, cursor, factory, grok, openclaw, pi, qwen, }; // Claude's two formats share one root and therefore one harness key: @@ -1062,6 +1064,38 @@ fn collector_tasks() -> Vec { redact, ); + let grok_roots = vec![grok_sessions_root()]; + file_source( + &mut tasks, + "grok", + grok::FORMAT, + grok_roots.clone(), + &extras("grok", &grok_roots), + grok::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + + let qwen_roots = vec![qwen_projects_root()]; + file_source( + &mut tasks, + "qwen", + qwen::FORMAT, + qwen_roots.clone(), + &extras("qwen", &qwen_roots), + qwen::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + let factory_roots = vec![factory_sessions_root()]; file_source( &mut tasks, @@ -1604,6 +1638,28 @@ fn codex_sessions_root() -> std::path::PathBuf { home.join(".codex").join("sessions") } +/// `~/.grok/sessions`, honouring the `GROK_HOME` override the audit side uses. +fn grok_sessions_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("GROK_HOME") { + return std::path::PathBuf::from(p).join("sessions"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".grok").join("sessions") +} + +/// `~/.qwen/projects`, honouring the `QWEN_HOME` override the audit side uses. +fn qwen_projects_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("QWEN_HOME") { + return std::path::PathBuf::from(p).join("projects"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".qwen").join("projects") +} + /// `~/.factory/sessions`, honouring the `FACTORY_HOME` override the audit /// adapter uses so tests can point at a fixture tree. fn factory_sessions_root() -> std::path::PathBuf { diff --git a/crates/fpai-collect/src/sources/grok/mod.rs b/crates/fpai-collect/src/sources/grok/mod.rs new file mode 100644 index 000000000..e6775bca8 --- /dev/null +++ b/crates/fpai-collect/src/sources/grok/mod.rs @@ -0,0 +1,86 @@ +//! grok CLI session capture — a [`filetail`](crate::filetail) adapter. +//! +//! grok stores a session as a DIRECTORY, not a file: +//! `~/.grok/sessions///chat_history.jsonl`, +//! beside `events.jsonl`, `summary.json` and lock files we never touch. We open +//! the transcript read-only; grok's own files are never written or moved. +//! +//! Three things make this the odd one out, and each shapes the code below: +//! +//! 1. **The session id is the PARENT DIRECTORY**, not the filename — every +//! session's transcript is called `chat_history.jsonl`. +//! 2. **The cwd folder is PERCENT-encoded** (`%2Fhome%2Fyou%2Frepo`), where +//! Claude/Factory/Qwen dash-encode. It decodes losslessly, so unlike those +//! the agent id can come straight from the path. +//! 3. **The transcript carries NO timestamps at all.** Per-event times live in +//! the sibling `events.jsonl`, which is not 1:1 with the turns. Rather than +//! mis-pair them this takes the cursor source's approach: stamp from the +//! file's mtime (`Ctx::file_epoch_ms`, captured once at discovery) plus the +//! byte offset, which keeps time approximately real AND a pure function of +//! the inputs, as the content-hash dedup requires. + +pub mod transform; + +use std::path::Path; + +use crate::filetail::{Format, RereadPolicy, no_seed_state}; + +/// Transcripts: a `chat_history.jsonl` whose parent directory is a uuid. +pub const FORMAT: Format = Format { + kind: "grok", + is_source_file: is_transcript, + session_id_from_path, + agent_id_from_path, + agent_start: transform::agent_start, + seed_state: no_seed_state, + agent_end: transform::agent_end, + transform_line: transform::transform_line, + // grok appends to chat_history.jsonl. A wholesale rewrite changes the size + // and the engine re-reads from zero; offset-keyed events then hash + // identically, so the server collapses the re-ship. + reread: RereadPolicy::ByteCursor, +}; + +/// The agent id used when the folder yields no project. +pub const DEFAULT_AGENT_ID: &str = "grok"; + +/// The one filename grok gives every session transcript. +const TRANSCRIPT: &str = "chat_history.jsonl"; + +const UUID_LEN: usize = 36; + +/// `8-4-4-4-12` hex, checked positionally. grok mints UUIDv7s, which are +/// positionally identical. +fn is_uuid36(s: &str) -> bool { + s.len() == UUID_LEN + && s.bytes().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// A transcript is exactly `/chat_history.jsonl`. The recursive walk means +/// this predicate is the whole filter; requiring both the filename and a uuid +/// parent excludes `events.jsonl`, `rewind_points.jsonl` and every lock file. +fn is_transcript(path: &Path) -> bool { + path.file_name().and_then(|n| n.to_str()) == Some(TRANSCRIPT) + && session_id_from_path(path).is_some() +} + +/// The session id is the PARENT directory name, since every transcript shares +/// one filename. +fn session_id_from_path(path: &Path) -> Option { + let dir = path.parent()?.file_name()?.to_str()?; + is_uuid36(dir).then(|| dir.to_string()) +} + +/// `grok-` from the percent-encoded cwd folder that CONTAINS the +/// session dir (`…/<%2Fpath%2Fto%2Frepo>//chat_history.jsonl`). +/// +/// Percent-encoding is reversible, so unlike Factory/Qwen this needs no lookup +/// inside the file — but the header is still consulted as a fallback for a +/// folder that fails to decode. +fn agent_id_from_path(path: &Path, _header: &[String]) -> Option { + let folder = path.parent()?.parent()?.file_name()?.to_str()?; + transform::agent_id_from_folder(folder) +} diff --git a/crates/fpai-collect/src/sources/grok/transform.rs b/crates/fpai-collect/src/sources/grok/transform.rs new file mode 100644 index 000000000..51fe3a3b0 --- /dev/null +++ b/crates/fpai-collect/src/sources/grok/transform.rs @@ -0,0 +1,274 @@ +//! grok transcript → AgentEye events. +//! +//! grok's lines are OpenAI-shaped, not Claude-shaped: an assistant turn is a +//! flat `content` string plus `tool_calls[]` whose `arguments` is a JSON +//! **string**, and a result is its own `{type:"tool_result", tool_call_id, +//! content}` line. `system` and `reasoning` lines are not turns. +//! +//! TIME: a grok transcript carries no timestamps on any line, so events are +//! stamped from the file's mtime (`ctx.file_epoch_ms`, captured once at +//! discovery and immutable) plus the byte offset in microseconds — the same +//! scheme the cursor source uses, and for the same reason: it keeps time about +//! right while staying a pure function of the inputs, so a re-read hashes +//! identically and the server collapses it. + +use serde_json::{Map, Value, json}; + +use crate::cursor::TailState; +use crate::filetail::Ctx; +// Reused, not re-cloned: `sanitize_id_part` MUST agree with the hook source's +// agent-id scheme so a hook event and this transcript's events share an id, and +// `to_rfc3339_micros` is a pure epoch→RFC3339 formatter. +use crate::sources::claude::transform::sanitize_id_part; +use crate::sources::goose::transform::to_rfc3339_micros; + +/// Longest session goal kept — the operator's first real prompt. +const MAX_GOAL_CHARS: usize = 500; + +/// A synthetic event timestamp: the file's mtime plus `offset` microseconds, +/// nudged by the block `index` so several events from one line keep their order. +fn synth_ts(ctx: &Ctx, offset: u64, index: usize) -> Option { + let base = ctx.file_epoch_ms.unwrap_or(0); + let micros = offset.saturating_add(index as u64); + to_rfc3339_micros( + base.saturating_add((micros / 1000) as i64), + (micros % 1000) as u32, + ) +} + +/// The envelope every emitted event carries. `grok_line_offset` is the dedup +/// discriminator: two identical events from different lines must hash +/// differently. +fn base(ctx: &Ctx, kind: &str, offset: u64, index: usize) -> Option> { + let mut m = Map::new(); + m.insert("timestamp".into(), json!(synth_ts(ctx, offset, index)?)); + m.insert("session_id".into(), json!(ctx.session_id)); + m.insert("agent_id".into(), json!(ctx.agent_id)); + m.insert("type".into(), json!(kind)); + m.insert("environment".into(), json!(ctx.environment)); + m.insert("grok_line_offset".into(), json!(offset)); + if index > 0 { + m.insert("grok_block_index".into(), json!(index)); + } + Some(m) +} + +/// Percent-decode grok's cwd folder, then derive `grok-`. +/// +/// Only `%XX` escapes appear in these names; anything malformed falls through +/// as a literal so a strange folder degrades to a visible id rather than none. +pub fn agent_id_from_folder(folder: &str) -> Option { + let decoded = percent_decode(folder); + let project = sanitize_id_part( + decoded + .trim_end_matches('/') + .rsplit('/') + .find(|p| !p.is_empty())?, + ); + (!project.is_empty()).then(|| format!("grok-{project}")) +} + +/// Minimal `%XX` decoder — no dependency, and the input alphabet is grok's own. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// `(type, value)` for a line, or `None` when it is not JSON. +fn parsed(line: &str) -> Option<(String, Value)> { + let v: Value = serde_json::from_str(line).ok()?; + let t = v.get("type")?.as_str()?.to_string(); + Some((t, v)) +} + +/// Text from a `content` that is either a string or an array of `{type:"text"}`. +fn content_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Array(items) => items + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// A real operator prompt: a `user` line carrying `prompt_index` and no +/// `synthetic_reason`. grok writes its environment preamble and its own +/// reminder injections as `user` lines too, and surfacing those as prompts +/// would make a session read as if the human pasted grok's boilerplate. +fn is_operator_prompt(v: &Value) -> bool { + v.get("prompt_index").is_some() && v.get("synthetic_reason").is_none() +} + +/// Build the session's `agent_start`; the goal is the first operator prompt. +pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option)> { + let mut m = base(ctx, "agent_start", offset, 0)?; + let goal = header.iter().find_map(|line| { + let (t, v) = parsed(line)?; + if t != "user" || !is_operator_prompt(&v) { + return None; + } + Some(content_text(v.get("content")?)) + }); + if let Some(goal) = goal.filter(|g| !g.trim().is_empty()) { + m.insert( + "goal".into(), + json!(goal.chars().take(MAX_GOAL_CHARS).collect::()), + ); + } + Some((Value::Object(m), synth_ts(ctx, offset, 0))) +} + +/// The single `agent_end`, at index 999 so it sorts after every content event, +/// stamped from the file size so it is the latest synthetic time in the session. +pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value { + match base(ctx, "agent_end", size, 999) { + Some(m) => Value::Object(m), + None => json!({ + "timestamp": last_ts, + "session_id": ctx.session_id, + "agent_id": ctx.agent_id, + "type": "agent_end", + "environment": ctx.environment, + }), + } +} + +/// One line to its timestamp and the events it yields. +/// +/// Every line gets a synthetic timestamp so `agent_end` tracks the file, even +/// for the `system` / `reasoning` lines that produce no events. +pub fn transform_line( + line: &str, + ctx: &Ctx, + offset: u64, + state: &mut TailState, +) -> (Option, Vec) { + let ts = synth_ts(ctx, offset, 0); + let Some((t, v)) = parsed(line) else { + return (ts, Vec::new()); + }; + let events = match t.as_str() { + // The system prompt and the model's private reasoning are not turns. + "system" | "reasoning" => Vec::new(), + "user" => user_events(&v, ctx, offset, state), + "assistant" => assistant_events(&v, ctx, offset, state), + "tool_result" => tool_result_events(&v, ctx, offset, state), + _ => Vec::new(), + }; + (ts, events) +} + +/// An operator prompt becomes one `model_request`; anything else is context. +fn user_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + if !is_operator_prompt(v) { + return Vec::new(); + } + let Some(content) = v.get("content") else { + return Vec::new(); + }; + let text = content_text(content); + if text.trim().is_empty() { + return Vec::new(); + } + let Some(mut m) = base(ctx, "model_request", offset, 0) else { + return Vec::new(); + }; + if let Some(model) = &state.last_model { + m.insert("model".into(), json!(model)); + } + m.insert( + "messages".into(), + json!([{ "role": "user", "content": text }]), + ); + vec![Value::Object(m)] +} + +/// An assistant line: its text, then each of its tool calls. +fn assistant_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + let model = v.get("model_id").and_then(|m| m.as_str()); + if let Some(model) = model { + state.last_model = Some(model.to_string()); + } + let mut out = Vec::new(); + + let text = v.get("content").map(content_text).unwrap_or_default(); + if !text.trim().is_empty() + && let Some(mut m) = base(ctx, "model_response", offset, 0) + { + m.insert("role".into(), json!("assistant")); + m.insert("content".into(), json!(text)); + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + + let calls = v.get("tool_calls").and_then(|c| c.as_array()); + for (i, call) in calls.into_iter().flatten().enumerate() { + // Index from 1: index 0 belongs to the text event above, and two events + // from one line must not share a synthetic timestamp. + let Some(mut m) = base(ctx, "tool_use", offset, i + 1) else { + continue; + }; + let name = call.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); + let id = call + .get("id") + .and_then(|x| x.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("grok-{offset}-{i}")); + state.remember_tool(id.clone(), name.to_string()); + m.insert("tool_name".into(), json!(name)); + m.insert("tool_call_id".into(), json!(id)); + // grok serializes arguments as a JSON STRING; parse it so tool inputs + // are queryable like every other source's, and keep the raw text when + // it will not parse rather than dropping the call's arguments. + if let Some(args) = call.get("arguments") { + let parsed_args = args + .as_str() + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or_else(|| args.clone()); + m.insert("input".into(), parsed_args); + } + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + out +} + +/// A `tool_result` line, paired back to its call by `tool_call_id`. +fn tool_result_events(v: &Value, ctx: &Ctx, offset: u64, state: &mut TailState) -> Vec { + let Some(mut m) = base(ctx, "tool_result", offset, 0) else { + return Vec::new(); + }; + if let Some(id) = v.get("tool_call_id").and_then(|x| x.as_str()) { + m.insert("tool_call_id".into(), json!(id)); + // The tool's name is on NO result line — carry it from the call, or + // every result is a blank row in the product. + if let Some(name) = state.tool_name(id) { + m.insert("tool_name".into(), json!(name)); + } + } + if let Some(content) = v.get("content") { + m.insert("output".into(), json!(content_text(content))); + } + vec![Value::Object(m)] +} diff --git a/crates/fpai-collect/src/sources/mod.rs b/crates/fpai-collect/src/sources/mod.rs index d70c0c543..ef627a16c 100644 --- a/crates/fpai-collect/src/sources/mod.rs +++ b/crates/fpai-collect/src/sources/mod.rs @@ -21,8 +21,10 @@ pub mod cursor; pub mod devin; pub mod factory; pub mod goose; +pub mod grok; pub mod hermes; pub mod hooks; pub mod openclaw; pub mod opencode; pub mod pi; +pub mod qwen; diff --git a/crates/fpai-collect/src/sources/qwen/mod.rs b/crates/fpai-collect/src/sources/qwen/mod.rs new file mode 100644 index 000000000..016445c2c --- /dev/null +++ b/crates/fpai-collect/src/sources/qwen/mod.rs @@ -0,0 +1,85 @@ +//! Qwen Code session capture — a [`filetail`](crate::filetail) adapter. +//! +//! qwen writes live-appended JSONL at +//! `~/.qwen/projects//chats/.jsonl`, one file per +//! session. We open them read-only; qwen's own files are never written, moved +//! or deleted. +//! +//! # Closest to Factory on disk, furthest from it inside +//! +//! The layout is Factory's (Claude-style encoded-cwd folder, `.jsonl` +//! stem) with one extra `chats/` level. The BODIES are not: qwen descends from +//! Gemini CLI, so a message is `message.parts[]` of `{text}` / +//! `{functionCall}` / `{functionResponse}` and the assistant role is spelled +//! `"model"`. See [`transform`]. +//! +//! # The encoded-cwd folder is lossy, so the agent id comes from inside +//! +//! Every line carries a real `cwd`, which is what [`agent_id_from_path`] reads +//! — matching the scheme the hook source derives from the same cwd, so a hook +//! event and this transcript's events share one agent id. + +pub mod transform; + +use std::path::Path; + +use crate::filetail::{Format, RereadPolicy, no_seed_state}; + +/// Transcripts: `.jsonl` directly inside a `chats/` directory. +pub const FORMAT: Format = Format { + kind: "qwen", + is_source_file: is_transcript, + session_id_from_path, + agent_id_from_path, + agent_start: transform::agent_start, + // Nothing needs priming: the model is on each assistant line's `model`. + seed_state: no_seed_state, + agent_end: transform::agent_end, + transform_line: transform::transform_line, + // qwen appends. A wholesale rewrite changes the size, and the engine's + // shrink-detection re-reads from zero — offset-keyed events then hash + // identically, so the server collapses the re-ship rather than doubling it. + reread: RereadPolicy::ByteCursor, +}; + +/// The agent id used when no line carries a cwd. +pub const DEFAULT_AGENT_ID: &str = "qwen"; + +const UUID_LEN: usize = 36; + +/// `8-4-4-4-12` hex, checked positionally. +fn is_uuid36(s: &str) -> bool { + s.len() == UUID_LEN + && s.bytes().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// A transcript is `.jsonl` whose parent directory is `chats`. The +/// recursive walk means this predicate is the whole filter; requiring the +/// `chats/` parent excludes any other `.jsonl` a widened root might contain. +fn is_transcript(path: &Path) -> bool { + if session_id_from_path(path).is_none() { + return false; + } + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + == Some("chats") +} + +/// The uuid embedded in the filename IS the session id. +fn session_id_from_path(path: &Path) -> Option { + let stem = path.file_name()?.to_str()?.strip_suffix(".jsonl")?; + is_uuid36(stem).then(|| stem.to_string()) +} + +/// `qwen-`, from the real `cwd` present on every line. +fn agent_id_from_path(_path: &Path, header: &[String]) -> Option { + let cwd = header.iter().find_map(|line| { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + v.get("cwd")?.as_str().map(str::to_string) + })?; + transform::agent_id_from_cwd(&cwd) +} diff --git a/crates/fpai-collect/src/sources/qwen/transform.rs b/crates/fpai-collect/src/sources/qwen/transform.rs new file mode 100644 index 000000000..959eb0cfa --- /dev/null +++ b/crates/fpai-collect/src/sources/qwen/transform.rs @@ -0,0 +1,297 @@ +//! Qwen Code transcript → AgentEye events. +//! +//! qwen's bodies are **Gemini-shaped, not Claude-shaped**: `message.parts[]` +//! holds `{text}`, `{functionCall:{id,name,args}}` and +//! `{functionResponse:{id,name,response}}`, and the assistant role is spelled +//! `"model"`. That is the whole reason this is not a clone of the Factory +//! transform despite the near-identical on-disk layout. +//! +//! Timestamps are real (top-level ISO `timestamp` on every line), so unlike the +//! cursor/grok sources nothing here is synthesised. + +use serde_json::{Map, Value, json}; + +use crate::cursor::TailState; +use crate::filetail::Ctx; +// Reused, not re-cloned: `sanitize_id_part` MUST agree with the hook source's +// so a hook event and this transcript's events land under one agent id, and +// `with_index` is a pure timestamp normaliser. +use crate::sources::claude::transform::{sanitize_id_part, with_index}; + +/// Longest session goal kept — the human's first prompt. +const MAX_GOAL_CHARS: usize = 500; + +/// The envelope every emitted event carries. +/// +/// `qwen_line_offset` is the dedup discriminator: two identical events from +/// different lines must hash differently. One transcript is one session, so a +/// byte offset is unique within it and stable across a re-read. +fn base(ctx: &Ctx, kind: &str, ts: &str, index: usize, offset: u64) -> Option> { + let mut m = Map::new(); + m.insert("timestamp".into(), json!(with_index(ts, index)?)); + m.insert("session_id".into(), json!(ctx.session_id)); + m.insert("agent_id".into(), json!(ctx.agent_id)); + m.insert("type".into(), json!(kind)); + m.insert("environment".into(), json!(ctx.environment)); + m.insert("qwen_line_offset".into(), json!(offset)); + if index > 0 { + m.insert("qwen_block_index".into(), json!(index)); + } + Some(m) +} + +/// Parse a line, keeping its `type` discriminator. +fn parsed(line: &str) -> Option<(String, Value)> { + let v: Value = serde_json::from_str(line).ok()?; + let t = v.get("type")?.as_str()?.to_string(); + Some((t, v)) +} + +/// Top-level ISO timestamp, only when the server can parse it. +fn ts_of(v: &Value) -> Option { + let ts = v.get("timestamp")?.as_str()?.to_string(); + with_index(&ts, 0).is_some().then_some(ts) +} + +/// Derive `qwen-` from an absolute cwd. +pub fn agent_id_from_cwd(cwd: &str) -> Option { + let project = sanitize_id_part( + cwd.trim_end_matches('/') + .rsplit('/') + .find(|p| !p.is_empty())?, + ); + (!project.is_empty()).then(|| format!("qwen-{project}")) +} + +/// Concatenate the `text` parts of a `message.parts[]` array. +fn parts_text(message: &Value) -> String { + message + .get("parts") + .and_then(|p| p.as_array()) + .map(|parts| { + parts + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + +/// Build the session's `agent_start` from its header: the first user prompt is +/// the goal, and the first parseable timestamp anchors the event. +pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option)> { + let mut goal: Option = None; + let mut first_ts: Option = None; + + for line in header { + let Some((t, v)) = parsed(line) else { continue }; + if first_ts.is_none() + && let Some(ts) = ts_of(&v) + { + first_ts = Some(ts); + } + if goal.is_none() && t == "user" { + let text = v.get("message").map(parts_text).unwrap_or_default(); + if !text.trim().is_empty() { + goal = Some(text.chars().take(MAX_GOAL_CHARS).collect()); + } + } + } + + let ts = first_ts?; + let mut m = base(ctx, "agent_start", &ts, 0, offset)?; + if let Some(g) = goal { + m.insert("goal".into(), json!(g)); + } + Some((Value::Object(m), Some(ts))) +} + +/// The single `agent_end`, at index 999 so it sorts after every content event +/// sharing its timestamp. +pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value { + match base(ctx, "agent_end", last_ts, 999, size) { + Some(m) => Value::Object(m), + None => json!({ + "timestamp": last_ts, + "session_id": ctx.session_id, + "agent_id": ctx.agent_id, + "type": "agent_end", + "environment": ctx.environment, + }), + } +} + +/// One content line to its timestamp and the events it yields. +/// +/// `system` lines are qwen's own bookkeeping and carry no turn, but still +/// return their timestamp so `agent_end` reflects when the file last moved. +pub fn transform_line( + line: &str, + ctx: &Ctx, + offset: u64, + state: &mut TailState, +) -> (Option, Vec) { + let Some((t, v)) = parsed(line) else { + return (None, Vec::new()); + }; + let ts = ts_of(&v); + if t == "system" { + return (ts, Vec::new()); + } + let (Some(ts), Some(message)) = (ts, v.get("message")) else { + return (ts_of(&v), Vec::new()); + }; + let model = v.get("model").and_then(|m| m.as_str()); + let role = message + .get("role") + .and_then(|r| r.as_str()) + .unwrap_or_default(); + + // Gemini spells the assistant "model"; accept both, and treat everything + // else (user, tool_result) as the inbound side. + let events = if role == "model" || role == "assistant" { + assistant_events(message, ctx, &ts, offset, state, model) + } else { + user_events(message, ctx, &ts, offset, state) + }; + (Some(ts), events) +} + +/// A user / tool_result message: prompt text and/or function responses. +fn user_events( + message: &Value, + ctx: &Ctx, + ts: &str, + offset: u64, + state: &mut TailState, +) -> Vec { + let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if let Some(fr) = part.get("functionResponse") { + let Some(mut m) = base(ctx, "tool_result", ts, i, offset) else { + continue; + }; + if let Some(id) = fr.get("id").and_then(|x| x.as_str()) { + m.insert("tool_call_id".into(), json!(id)); + // The name is on the response too, but carry the remembered one + // when it is not, so no result renders as a blank row. + if let Some(name) = state.tool_name(id) { + m.insert("tool_name".into(), json!(name)); + } + } + if let Some(name) = fr.get("name").and_then(|n| n.as_str()) { + m.insert("tool_name".into(), json!(name)); + } + if let Some(resp) = fr.get("response") { + m.insert("output".into(), json!(stringify(resp))); + // qwen reports a failed call as `response.error`. + if let Some(err) = resp.get("error") { + m.insert("error".into(), json!(stringify(err))); + m.insert("error_type".into(), json!("qwen_tool_error")); + } + } + out.push(Value::Object(m)); + continue; + } + let Some(text) = part.get("text").and_then(|t| t.as_str()) else { + continue; + }; + if text.trim().is_empty() { + continue; + } + let Some(mut m) = base(ctx, "model_request", ts, i, offset) else { + continue; + }; + if let Some(model) = &state.last_model { + m.insert("model".into(), json!(model)); + } + m.insert( + "messages".into(), + json!([{ "role": "user", "content": text }]), + ); + out.push(Value::Object(m)); + } + out +} + +/// An assistant message: text and/or function calls. +fn assistant_events( + message: &Value, + ctx: &Ctx, + ts: &str, + offset: u64, + state: &mut TailState, + model: Option<&str>, +) -> Vec { + if let Some(model) = model { + state.last_model = Some(model.to_string()); + } + let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if let Some(fc) = part.get("functionCall") { + let Some(mut m) = base(ctx, "tool_use", ts, i, offset) else { + continue; + }; + let name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); + // Fall back to an offset-derived id so parallel identical calls + // cannot hash-collapse into one row. + let id = fc + .get("id") + .and_then(|x| x.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("qwen-{offset}-{i}")); + state.remember_tool(id.clone(), name.to_string()); + m.insert("tool_name".into(), json!(name)); + m.insert("tool_call_id".into(), json!(id)); + if let Some(args) = fc.get("args") { + m.insert("input".into(), args.clone()); + } + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + continue; + } + let Some(text) = part.get("text").and_then(|t| t.as_str()) else { + continue; + }; + if text.trim().is_empty() { + continue; + } + let Some(mut m) = base(ctx, "model_response", ts, i, offset) else { + continue; + }; + m.insert("role".into(), json!("assistant")); + m.insert("content".into(), json!(text)); + if let Some(model) = model { + m.insert("model".into(), json!(model)); + } + out.push(Value::Object(m)); + } + out +} + +/// Flatten a `functionResponse.response` payload to display text. +fn stringify(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(map) => { + for key in ["output", "result", "content", "error"] { + if let Some(Value::String(s)) = map.get(key) + && !s.is_empty() + { + return s.clone(); + } + } + v.to_string() + } + other => other.to_string(), + } +} diff --git a/crates/fpai-collect/tests/grok_qwen_sources.rs b/crates/fpai-collect/tests/grok_qwen_sources.rs new file mode 100644 index 000000000..9a56c2a44 --- /dev/null +++ b/crates/fpai-collect/tests/grok_qwen_sources.rs @@ -0,0 +1,298 @@ +//! grok + qwen sources: path predicates and transform correctness. +//! +//! Record shapes are taken verbatim from real transcripts captured on +//! grok 1.0.3 (`~/.grok/sessions///chat_history.jsonl`) and +//! qwen-code 0.21.12 (`~/.qwen/projects//chats/.jsonl`). +//! +//! These two are near-mirror-images and the tests below lean on that: grok has +//! no timestamps and an OpenAI-shaped body, qwen has real timestamps and a +//! Gemini-shaped one. + +use std::path::PathBuf; + +use fpai_collect::cursor::TailState; +use fpai_collect::filetail::Ctx; +use fpai_collect::sources::{grok, qwen}; +use serde_json::{Value, json}; + +const UUID: &str = "01a01432-6b6c-7593-9de8-54cd7ddd6fe6"; + +fn grok_ctx() -> Ctx { + Ctx { + session_id: UUID.into(), + agent_id: "grok-VTU".into(), + environment: "local".into(), + // grok transcripts carry no timestamps; the engine hands the file mtime. + file_epoch_ms: Some(1_760_000_000_000), + } +} + +fn qwen_ctx() -> Ctx { + Ctx { + session_id: UUID.into(), + agent_id: "qwen-VTU".into(), + environment: "local".into(), + ..Default::default() + } +} + +fn kinds(events: &[Value]) -> Vec { + events + .iter() + .filter_map(|e| e.get("type")?.as_str().map(str::to_string)) + .collect() +} + +// ── grok ───────────────────────────────────────────────────────────────────── + +#[test] +fn grok_identifies_only_chat_history_under_a_uuid_dir() { + let base = PathBuf::from("/home/u/.grok/sessions/%2Fhome%2Fu%2Frepo"); + let ok = base.join(UUID).join("chat_history.jsonl"); + assert!((grok::FORMAT.is_source_file)(&ok)); + // The session id is the PARENT dir, since every transcript shares a name. + assert_eq!( + (grok::FORMAT.session_id_from_path)(&ok).as_deref(), + Some(UUID) + ); + // Siblings in the very same directory must never be tailed. + for sibling in ["events.jsonl", "rewind_points.jsonl", "summary.json"] { + let p = base.join(UUID).join(sibling); + assert!( + !(grok::FORMAT.is_source_file)(&p), + "{sibling} must not be treated as a transcript" + ); + } + // A chat_history.jsonl not under a uuid directory is not a session. + assert!(!(grok::FORMAT.is_source_file)( + &base.join("chat_history.jsonl") + )); +} + +#[test] +fn grok_agent_id_percent_decodes_the_cwd_folder() { + let p = PathBuf::from("/home/u/.grok/sessions/%2Fhome%2Fchetan%2FDesktop%2FVTU") + .join(UUID) + .join("chat_history.jsonl"); + // Percent-encoding is reversible, so unlike Factory/Qwen the id needs no + // lookup inside the file. + assert_eq!( + (grok::FORMAT.agent_id_from_path)(&p, &[]).as_deref(), + Some("grok-VTU") + ); +} + +#[test] +fn grok_skips_system_and_reasoning_but_keeps_a_timestamp() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + for line in [ + json!({"type": "system", "content": "You are Grok…"}).to_string(), + json!({"type": "reasoning", "id": "rs_1", "summary": []}).to_string(), + ] { + let (ts, events) = (grok::FORMAT.transform_line)(&line, &ctx, 10, &mut state); + assert!(events.is_empty(), "system/reasoning are not turns"); + // Still timestamped, so agent_end tracks the file. + assert!(ts.is_some()); + } +} + +#[test] +fn grok_counts_only_operator_prompts_as_user_turns() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + + // The environment preamble: a `user` line with no prompt_index. + let preamble = + json!({"type": "user", "content": [{"type": "text", "text": "…"}]}).to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&preamble, &ctx, 1, &mut state); + assert!( + events.is_empty(), + "preamble must not read as a human prompt" + ); + + // An injected reminder is also a `user` line. + let synthetic = json!({"type": "user", "synthetic_reason": "skills", + "content": [{"type": "text", "text": "…"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&synthetic, &ctx, 2, &mut state); + assert!(events.is_empty(), "synthetic injections are not prompts"); + + // The real thing carries prompt_index. + let real = json!({"type": "user", "prompt_index": 0, + "content": [{"type": "text", "text": "run echo hi"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&real, &ctx, 3, &mut state); + assert_eq!(kinds(&events), vec!["model_request"]); +} + +#[test] +fn grok_parses_tool_arguments_from_their_json_string() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({ + "type": "assistant", "content": "I'll run it.", "model_id": "grok-4.6", + "tool_calls": [{ + "id": "call-1", "name": "run_terminal_command", + // grok serializes arguments as a JSON STRING, not an object. + "arguments": "{\"command\":\"echo hi\",\"description\":\"Echo\"}" + }] + }) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&line, &ctx, 100, &mut state); + assert_eq!(kinds(&events), vec!["model_response", "tool_use"]); + + let call = &events[1]; + assert_eq!(call["tool_name"], json!("run_terminal_command")); + assert_eq!(call["input"]["command"], json!("echo hi")); + assert_eq!(call["model"], json!("grok-4.6")); + + // Two events off one line must not share a synthetic timestamp, or the + // content-hash dedup collapses them into one row. + assert_ne!(events[0]["timestamp"], events[1]["timestamp"]); + + // The result line carries no tool name; it must be carried from the call. + let result = + json!({"type": "tool_result", "tool_call_id": "call-1", "content": "exit: 0\nhi\n"}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&result, &ctx, 200, &mut state); + assert_eq!(kinds(&events), vec!["tool_result"]); + assert_eq!(events[0]["tool_name"], json!("run_terminal_command")); + assert_eq!(events[0]["output"], json!("exit: 0\nhi\n")); +} + +#[test] +fn grok_keeps_unparseable_tool_arguments_rather_than_dropping_them() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "assistant", "content": "", + "tool_calls": [{"id": "c", "name": "x", "arguments": "{not json"}]}) + .to_string(); + let (_, events) = (grok::FORMAT.transform_line)(&line, &ctx, 5, &mut state); + assert_eq!(events[0]["input"], json!("{not json")); +} + +#[test] +fn grok_stamps_events_from_the_file_mtime() { + let ctx = grok_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "user", "prompt_index": 0, + "content": [{"type": "text", "text": "hi"}]}) + .to_string(); + let (_, a) = (grok::FORMAT.transform_line)(&line, &ctx, 0, &mut state); + let (_, b) = (grok::FORMAT.transform_line)(&line, &ctx, 5_000, &mut state); + // Offset advances synthetic time, so ordering within a session is right and + // a re-read at the same offset reproduces the same value. + assert!(a[0]["timestamp"].as_str().unwrap() < b[0]["timestamp"].as_str().unwrap()); +} + +// ── qwen ───────────────────────────────────────────────────────────────────── + +#[test] +fn qwen_identifies_only_uuid_jsonl_inside_a_chats_dir() { + let base = PathBuf::from("/home/u/.qwen/projects/-home-u-repo"); + let ok = base.join("chats").join(format!("{UUID}.jsonl")); + assert!((qwen::FORMAT.is_source_file)(&ok)); + assert_eq!( + (qwen::FORMAT.session_id_from_path)(&ok).as_deref(), + Some(UUID) + ); + // Same filename one level up is not a transcript — the `chats/` parent is + // what separates qwen's layout from Factory's. + assert!(!(qwen::FORMAT.is_source_file)( + &base.join(format!("{UUID}.jsonl")) + )); +} + +#[test] +fn qwen_agent_id_comes_from_the_cwd_on_any_line() { + let header = vec![json!({"type": "user", "cwd": "/home/chetan/Desktop/VTU"}).to_string()]; + let p = PathBuf::from("/home/u/.qwen/projects/-x/chats").join(format!("{UUID}.jsonl")); + assert_eq!( + (qwen::FORMAT.agent_id_from_path)(&p, &header).as_deref(), + Some("qwen-VTU") + ); +} + +#[test] +fn qwen_reads_gemini_shaped_parts() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + + // The assistant role is spelled "model" (Gemini lineage), and a tool call is + // a `functionCall` part rather than a Claude `tool_use` block. + let line = json!({ + "type": "assistant", "uuid": "a1", "model": "gpt-5.6-luna", + "timestamp": "2026-08-16T18:30:02.000Z", + "message": {"role": "model", "parts": [ + {"text": "Creating it now."}, + {"functionCall": {"id": "call_1", "name": "write_file", + "args": {"file_path": "/tmp/a.txt", "content": "alpha"}}} + ]} + }) + .to_string(); + let (ts, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 100, &mut state); + // Real timestamps, unlike grok — nothing synthesised. + assert_eq!(ts.as_deref(), Some("2026-08-16T18:30:02.000Z")); + assert_eq!(kinds(&events), vec!["model_response", "tool_use"]); + assert_eq!(events[1]["tool_name"], json!("write_file")); + // qwen's args are already canonical — no input map anywhere in the stack. + assert_eq!(events[1]["input"]["file_path"], json!("/tmp/a.txt")); + + // A functionResponse pairs back by id and inherits the remembered name. + let result = json!({ + "type": "tool_result", "uuid": "t1", "timestamp": "2026-08-16T18:30:03.000Z", + "message": {"role": "user", "parts": [ + {"functionResponse": {"id": "call_1", "name": "write_file", + "response": {"output": "written"}}} + ]} + }) + .to_string(); + let (_, events) = (qwen::FORMAT.transform_line)(&result, &ctx, 200, &mut state); + assert_eq!(kinds(&events), vec!["tool_result"]); + assert_eq!(events[0]["tool_name"], json!("write_file")); + assert_eq!(events[0]["output"], json!("written")); +} + +#[test] +fn qwen_marks_a_failed_call_as_an_error() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + let line = json!({ + "type": "tool_result", "timestamp": "2026-08-16T18:30:03.000Z", + "message": {"role": "user", "parts": [ + {"functionResponse": {"id": "c1", "name": "read_file", + "response": {"error": "File not found: /tmp/x"}}} + ]} + }) + .to_string(); + let (_, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 1, &mut state); + assert_eq!(events[0]["error_type"], json!("qwen_tool_error")); + assert_eq!(events[0]["error"], json!("File not found: /tmp/x")); +} + +#[test] +fn qwen_skips_system_bookkeeping_lines() { + let ctx = qwen_ctx(); + let mut state = TailState::default(); + let line = json!({"type": "system", "subtype": "info", "systemPayload": {}, + "timestamp": "2026-08-16T18:30:01.000Z"}) + .to_string(); + let (ts, events) = (qwen::FORMAT.transform_line)(&line, &ctx, 1, &mut state); + assert!(events.is_empty()); + assert_eq!(ts.as_deref(), Some("2026-08-16T18:30:01.000Z")); +} + +#[test] +fn qwen_agent_start_takes_the_first_prompt_as_the_goal() { + let ctx = qwen_ctx(); + let header = vec![ + json!({"type": "user", "cwd": "/x", "timestamp": "2026-08-16T18:30:00.000Z", + "message": {"role": "user", "parts": [{"text": "create report.txt"}]}}) + .to_string(), + ]; + let (start, seed) = (qwen::FORMAT.agent_start)(&header, &ctx, 0).expect("agent_start"); + assert_eq!(start["type"], json!("agent_start")); + assert_eq!(start["goal"], json!("create report.txt")); + assert_eq!(seed.as_deref(), Some("2026-08-16T18:30:00.000Z")); +} diff --git a/docs/index.mdx b/docs/index.mdx index 1f249d615..4ae0f5e8f 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -6,7 +6,7 @@ icon: "shield-check" Failproof AI helps anyone running agents understand what happened, find failures, and prevent them from repeating. -It works with 12 common agent environments, including Claude Code, Codex, Hermes, OpenClaw, and Goose. Agents built with LangChain, CrewAI, LlamaIndex, Pydantic AI, or your own runtime can report through the [Python SDK](/reference/custom-agents). +It works with 16 common agent environments, including Claude Code, Codex, Cline, Hermes, OpenClaw, Goose, grok and Ori. Agents built with LangChain, CrewAI, LlamaIndex, Pydantic AI, or your own runtime can report through the [Python SDK](/reference/custom-agents). diff --git a/docs/policies/builtin.mdx b/docs/policies/builtin.mdx index c9a430fc8..f1d7c4e0c 100644 --- a/docs/policies/builtin.mdx +++ b/docs/policies/builtin.mdx @@ -82,7 +82,7 @@ A policy only changes behavior where the harness consumes the verdict for its ev | Event | Where a deny is verified to change behavior | | --- | --- | -| `PreToolUse` | All 12 harnesses | +| `PreToolUse` | All 16 harnesses | | `Stop` | claude, codex, copilot, cursor, openclaw, factory, devin, antigravity. No `Stop` hook is installed on goose or hermes, pi carries the reason into the next turn instead, and opencode is not verified | So the five `require-*-before-stop` policies in the Workflow category can be enabled on a machine and still never fire, depending on which agent runs there. Cursor Cloud Agent VMs run no stop hooks at all. diff --git a/docs/policies/overview.mdx b/docs/policies/overview.mdx index fe488699a..14c834136 100644 --- a/docs/policies/overview.mdx +++ b/docs/policies/overview.mdx @@ -72,5 +72,5 @@ For a pack used without Cloud, publish it with `failproofai publish --effect obs - Enforcement support varies by harness. A tool-call deny is verified on all 12 supported harnesses; other events vary. See [supported harnesses](/reference/harnesses#enforcement-capability). + Enforcement support varies by harness. A tool-call deny is verified on all 16 supported harnesses; other events vary. See [supported harnesses](/reference/harnesses#enforcement-capability). diff --git a/docs/reference/harnesses.mdx b/docs/reference/harnesses.mdx index 5e4cb06bf..350411bdd 100644 --- a/docs/reference/harnesses.mdx +++ b/docs/reference/harnesses.mdx @@ -1,17 +1,17 @@ --- title: "Agent harnesses" -description: "Capture sessions and enforce policies across all 12 supported agent harnesses." +description: "Capture sessions and enforce policies across all 16 supported agent harnesses." icon: "plug-zap" --- -A harness is whatever your agent actually runs inside. Failproof AI supports twelve of them, in two classes: +A harness is whatever your agent actually runs inside. Failproof AI supports sixteen of them, in two classes: -- **Coding CLIs** (10) — Claude Code, Codex, GitHub Copilot CLI, Cursor, OpenCode, Pi, Factory Droid, Devin CLI, Antigravity CLI, Goose +- **Coding CLIs** (14) — Claude Code, Codex, GitHub Copilot CLI, Cursor, OpenCode, Pi, Factory Droid, Devin CLI, Antigravity CLI, Goose, grok CLI, Qwen Code, Ori, Cline - **Chat and assistant gateways** (2) — Hermes (Slack, Telegram, cron), OpenClaw (self-hosted assistant) The same policies and the same session history apply whichever one an agent runs in. One adapter layer maps each harness's native event names, tool names, and tool-input fields onto 29 canonical events before any policy runs. -An agent that runs in **none** of the twelve is instrumented directly with the [Python SDK](/reference/custom-agents). That is a different contract, and worth stating plainly: the SDK delivers tracing, sessions, evaluations and audits — **it does not enforce policies on its own.** Blocking an unsafe action before it executes needs an enforcement hook at your runtime's tool boundary; [contact us](mailto:support@befailproof.ai) and we will map it. +An agent that runs in **none** of the sixteen is instrumented directly with the [Python SDK](/reference/custom-agents). That is a different contract, and worth stating plainly: the SDK delivers tracing, sessions, evaluations and audits — **it does not enforce policies on its own.** Blocking an unsafe action before it executes needs an enforcement hook at your runtime's tool boundary; [contact us](mailto:support@befailproof.ai) and we will map it. ## Hook scopes @@ -20,9 +20,10 @@ An agent that runs in **none** of the twelve is instrumented directly with the [ | Claude Code | User, project, local | | Codex, GitHub Copilot CLI, Cursor, OpenCode, Pi | User, project | | Factory Droid, Devin CLI, Antigravity CLI, Goose | User, project | -| Hermes, OpenClaw | User | +| grok CLI, Qwen Code, Cline | User, project | +| Hermes, OpenClaw, Ori | User | -Claude Code is the only harness with a **local** scope. Hermes and OpenClaw have no project configuration at all — they are user scope only, and the CLI refuses `--scope project` for them. +Claude Code is the only harness with a **local** scope. Hermes, OpenClaw and Ori have no project configuration at all — they are user scope only, and the CLI refuses `--scope project` for them. Ori is user scope for a different reason than the gateways: bare `ori` boots its **global** workspace rather than the project's, so one install covers every project. Each integration normalizes its native hook event names, tool names, and tool-input fields before policies run. A policy can only act on events the harness exposes; test end-of-turn and instruction behavior on the exact harness and version you deploy. @@ -42,8 +43,16 @@ Each integration normalizes its native hook event names, tool names, and tool-in | Devin CLI | `~/.config/devin/config.json` | `.devin/config.json` | | Antigravity CLI | `~/.gemini/config/hooks.json` | `.agents/hooks.json` | | Goose | `~/.agents/plugins/failproofai/hooks/hooks.json` | `.agents/plugins/failproofai/hooks/hooks.json` | +| grok CLI | `~/.grok/hooks/failproofai.json` | `.grok/hooks/failproofai.json` | +| Qwen Code | `~/.qwen/settings.json` | `.qwen/settings.json` | +| Cline | `~/.cline/hooks/` | `.clinerules/hooks/` | +| Ori | `~/.ori/global/features/failproofai/` | — | -OpenCode, Pi and OpenClaw are plugin integrations rather than shell-hook integrations: the file above registers a plugin or extension package, which calls the failproofai binary and translates its verdict. +OpenCode, Pi, OpenClaw and Ori are plugin integrations rather than shell-hook integrations: the file above registers a plugin or extension package, which calls the failproofai binary and translates its verdict. Ori needs no registration step — a feature directory dropped into its workspace is auto-discovered, as with Goose's plugin directory. + +### Ori is also a launcher, and that path needs nothing + +`ori` runs other agent CLIs under OpenRouter credentials (`ori claude`, `ori codex`, `ori grok`, `ori opencode`, `ori hermes`, and others). On that path it injects credentials and nothing else — it does not redirect `HOME` or any configuration directory, and the settings it does inject **merge** rather than replace. So the hooks you already installed for those harnesses keep enforcing under `ori `, with no ori-specific install. The `ori` integration below covers only ori's **own** agent, reached with bare `ori` or `ori code`. ## Enforcement capability @@ -63,6 +72,10 @@ OpenCode, Pi and OpenClaw are plugin integrations rather than shell-hook integra | Devin CLI | `PreToolUse`, `UserPromptSubmit`, `Stop`, conditional `PermissionRequest` | Permission hooks do not run in every permission mode; post-tool and session events are observational. | | Antigravity CLI | `PreToolUse`, `Stop` | User-prompt and post-tool verdicts are observational; prompt instructions can still be injected. | | Goose | `PreToolUse` | User-prompt, post-tool, and session events are observational. A native blocking stop hook exists upstream but is not installed by the current adapter. | +| grok CLI | `PreToolUse`, `Stop`, `SubagentStop` | grok advertises exactly three blocking events; user-prompt, post-tool and session events are observational no matter what verdict we send. | +| Qwen Code | `PreToolUse`, `Stop`, `TaskCreated`, `TaskCompleted` | Post-tool, permission, session and subagent events are observational in the current adapter. | +| Cline | `PreToolUse` | A deny **aborts the whole run**, not just the tool — see the condition below. Stop, post-tool, prompt and session events are installed for the audit trail but no verdict on them is verified. | +| Ori | `PreToolUse` | The only gate ori exposes. **Requires manual approval mode** — see the condition below. No stop, prompt, post-tool or session event exists to subscribe to. | An event absent from both columns is **not verified** — treat it as unknown, never as blocking. @@ -81,6 +94,13 @@ Several rows above are real gates that are nonetheless bounded or conditional. A | OpenCode | `PermissionRequest` | A dead hook: `permission.ask` is declared and documented upstream but never invoked, so the policy does not even run | | Hermes | `Stop` | No `Stop` event is installed, by choice. The five `require-*-before-stop` builtins are inapplicable on Hermes | | Goose | `Stop` | Same outcome: no `Stop` is installed, so the five `require-*-before-stop` builtins are inapplicable | +| grok CLI | `Stop` | Honoured only when the turn ends with `reason: "end_turn"`; the session-shutdown fire is parsed and discarded upstream. Capped at 8 continuations per turn | +| grok CLI | project scope | grok discovers project hooks only inside a **git repo**, and only in a folder it considers **trusted** | +| Qwen Code | `Stop` | `stop_hook_active` is already true on the FIRST fire, so it cannot be used as a loop guard | +| Ori | `PreToolUse` | **Only under `--approvals manual`.** Ori's default mode, `self-drive`, approves every command without consulting the dynamic approval points, so policies never run. Switch with `ori code --approvals manual` or `/approvals` in the TUI | +| Ori | `Stop` | No `Stop` event exists, so the five `require-*-before-stop` builtins are inapplicable | +| Cline | `PreToolUse` | A deny is `{"cancel":true}`, which cline turns into a `ControlledStopError` — it **aborts the entire run**, not just the tool call. Stronger than a per-tool deny, not weaker | +| Cline | `Stop` | `TaskComplete` maps here, but the task has already finished, so a cancel would kill a completed run rather than force a retry. The five `require-*-before-stop` builtins are inapplicable | ### Where `instruct()` degrades @@ -90,6 +110,7 @@ A `deny` is not the only verdict a policy returns. `instruct()` hands the agent | --- | --- | | Hermes | Every event | | Goose | Every event | +| Ori | Every event | | Pi | Every event except `Stop` | | OpenClaw | Every event except `Stop` | | Factory Droid | Every event except `Stop` | @@ -115,6 +136,10 @@ A version is part of the claim, not a footnote. Re-test after upgrading an agent | Devin CLI | 3000.2.17 | | Antigravity CLI | agy 1.1.8 | | Goose | 1.43.0 | +| grok CLI | 1.0.3 (`1a29d5bc12`) | +| Qwen Code | qwen-code 0.21.12 | +| Ori | 0.12.0+68f9a36 | +| Cline | 3.0.60 | Several Codex rows cite source paths that were restructured after `fe01054a` and no longer exist at 0.147.0. They are not known-wrong, but they are unverified against any shipping Codex and are due for a re-probe. Only the Codex `PostToolUse` row was re-probed live. @@ -122,7 +147,7 @@ A version is part of the claim, not a footnote. Re-test after upgrading an agent ## VS Code agent mode -VS Code's built-in Copilot Chat agent mode is **not a thirteenth integration**. It discovers hook configuration from `.github/hooks/*.json`, `~/.copilot/hooks/*.json` and `~/.claude/settings.json` — exactly the paths the `copilot` and `claude` installs already write — and uses the same Claude-shaped deny contract. Installing either one already enforces inside VS Code agent-mode sessions. +VS Code's built-in Copilot Chat agent mode is **not a seventeenth integration**. It discovers hook configuration from `.github/hooks/*.json`, `~/.copilot/hooks/*.json` and `~/.claude/settings.json` — exactly the paths the `copilot` and `claude` installs already write — and uses the same Claude-shaped deny contract. Installing either one already enforces inside VS Code agent-mode sessions. The feature is a preview and needs an active GitHub Copilot subscription plus agent mode. diff --git a/docs/reference/policy-sdk.mdx b/docs/reference/policy-sdk.mdx index 3e8078f11..b219d0cd3 100644 --- a/docs/reference/policy-sdk.mdx +++ b/docs/reference/policy-sdk.mdx @@ -146,7 +146,7 @@ const filePath = String(ctx.toolInput?.file_path ?? ""); | Event | When it runs | Typical use | | --- | --- | --- | | `PreToolUse` | Before a tool executes. | Block or guide commands, writes, reads, and external actions. This is the guard. | -| `PostToolUse` | After the tool has already executed. | Inspect or replace the result the model reads. It cannot undo the side effect—the write or the command already happened—and on ten of the twelve harnesses the verdict cannot stop anything. Only Codex and Copilot consume it, and even there it replaces the result the model reads rather than preventing the call. Never use it as a guard. | +| `PostToolUse` | After the tool has already executed. | Inspect or replace the result the model reads. It cannot undo the side effect—the write or the command already happened—and on fourteen of the sixteen harnesses the verdict cannot stop anything. Only Codex and Copilot consume it, and even there it replaces the result the model reads rather than preventing the call. Never use it as a guard. | | `PermissionRequest` | When the agent requests permission. | Apply organization-specific permission rules. | | `UserPromptSubmit` | Before a submitted prompt continues. | Reject prohibited instructions or add workflow guidance. | | `Stop` | When the agent attempts to finish. | Require a reachable completion condition, such as a local verification step. | diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 39bcc45bd..bc23a5921 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -89,7 +89,7 @@ Check three things: 2. Its event and tool match the action. 3. That harness can enforce the event. -A tool-call deny is verified on all 12 supported harnesses. Other events vary. See [enforcement capability](/reference/harnesses#enforcement-capability). +A tool-call deny is verified on all 16 supported harnesses. Other events vary. See [enforcement capability](/reference/harnesses#enforcement-capability). ## Downloads are blocked diff --git a/docs/sessions/policy-decisions.mdx b/docs/sessions/policy-decisions.mdx index 9a3349f2e..0da9adde4 100644 --- a/docs/sessions/policy-decisions.mdx +++ b/docs/sessions/policy-decisions.mdx @@ -40,7 +40,7 @@ fp fleet deploy --add :enforce Only non-allow observed decisions are recorded. A timeout records as an allow, because that is also what happens in enforce mode. -A recorded deny is not proof that the harness stopped the action. Tool-call denies are verified across all 12 supported harnesses; other events vary. See [enforcement capability](/reference/harnesses#enforcement-capability). +A recorded deny is not proof that the harness stopped the action. Tool-call denies are verified across all 16 supported harnesses; other events vary. See [enforcement capability](/reference/harnesses#enforcement-capability). ## Investigate an unexpected result diff --git a/docs/start/quickstart.mdx b/docs/start/quickstart.mdx index be6376035..1bcefbc34 100644 --- a/docs/start/quickstart.mdx +++ b/docs/start/quickstart.mdx @@ -84,8 +84,8 @@ the CLI installation below. Run one of your agents and ask it to perform a normal task. Then: -A tool-call deny is verified on all 12 supported harnesses. Turn-end enforcement -is verified on 8; other harness-event pairs may be observation-only or unverified. +A tool-call deny is verified on all 16 supported harnesses. Turn-end enforcement +is verified on 10; other harness-event pairs may be observation-only or unverified. - Open **Policies → Activity** in the local dashboard to see policy decisions. - Open **Observe → Sessions** in Cloud to see the full run. diff --git a/lib/cli-registry.ts b/lib/cli-registry.ts index 031aea4c9..dde437c5d 100644 --- a/lib/cli-registry.ts +++ b/lib/cli-registry.ts @@ -27,7 +27,7 @@ import type { IntegrationType } from "@/src/hooks/types"; /** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */ -export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const satisfies readonly IntegrationType[]; +export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"] as const satisfies readonly IntegrationType[]; export type CliId = (typeof KNOWN_CLI_IDS)[number]; /** Per-CLI metadata consumed by the dashboard. */ @@ -99,6 +99,38 @@ const CLI_ENTRIES: Record = { label: "Goose", badgeClasses: "bg-lime-500/10 text-lime-400 border-lime-500/20", }, + grok: { + id: "grok", + label: "grok CLI", + // Neutral zinc, matching the design system's `--color-default`. Deliberately + // NOT a status hue: green/amber/red carry health meaning in this dashboard + // and must never be spent on identity. Neutral also happens to suit xAI's + // monochrome brand, and it is maximally far from qwen's magenta — the two + // were previously indistinguishable. + badgeClasses: "bg-zinc-500/10 text-zinc-200 border-zinc-500/20", + }, + qwen: { + id: "qwen", + label: "Qwen Code", + // Magenta — the opposite pole from grok's neutral, and outside the + // status set (success/amber/orange/error) so it reads as identity. + badgeClasses: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20", + }, + ori: { + id: "ori", + label: "Ori", + // Sky — the badge set must be UNIQUE per CLI (a test asserts it), and + // violet/teal were already taken. Still outside the status set + // (success/amber/orange/error) so it reads as identity, not state. + badgeClasses: "bg-sky-500/10 text-sky-400 border-sky-500/20", + }, + cline: { + id: "cline", + label: "Cline", + // Stone — the remaining neutral that is not zinc (grok's) and not a status + // colour. + badgeClasses: "bg-stone-500/10 text-stone-300 border-stone-500/20", + }, }; export function getCliEntry(id: string): CliEntry | undefined { diff --git a/lib/cline-projects.ts b/lib/cline-projects.ts new file mode 100644 index 000000000..11b3bbcad --- /dev/null +++ b/lib/cline-projects.ts @@ -0,0 +1,155 @@ +/** + * Cline session enumeration — AUDIT-ONLY. + * + * One directory per session under `~/.cline/data/sessions/_/`, + * each with a `.json` metadata sidecar carrying a real `cwd`. So cline + * sessions group by project like Claude / Factory / Devin / Goose / ori rather + * than landing in one cwd-less bucket the way the Hermes gateway does. + * + * Enumeration reads only the small metadata sidecar, never the transcript — a + * project listing should not pay for parsing every message array on the box. + */ +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { + clineSessionsDir, + clineTimestampToMs, + readClineSessionMeta, + CLINE_SESSION_DIR_RE, +} from "./cline-sessions"; +import { encodeFolderName } from "./paths"; +import { runtimeCache } from "./runtime-cache"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { formatDate } from "./format-date"; + +export interface ClineSessionRef { + sessionId: string; + cwd?: string; + title?: string; + model?: string; + provider?: string; + /** Encoded-cwd folder slug (matches Claude's `-home-user-project` scheme). */ + projectName: string; + mtimeMs: number; +} + +/** Truncate a first prompt into a session title the way the other adapters do. */ +function titleFrom(prompt: string | undefined): string | undefined { + if (!prompt) return undefined; + // cline wraps the first turn in . + const inner = /]*>([\s\S]*?)<\/user_input>/.exec(prompt); + const text = (inner ? inner[1]! : prompt).trim().replace(/\s+/g, " "); + if (text.length === 0) return undefined; + return text.length > 60 ? `${text.slice(0, 59)}…` : text; +} + +/** + * List every cline session. Returns `[]` when the directory is missing or + * unreadable (fail-open — the audit just skips cline). + */ +export async function getClineSessions(): Promise { + const root = clineSessionsDir(); + let names: string[]; + try { + names = await readdir(root); + } catch { + return []; + } + const out: ClineSessionRef[] = []; + for (const name of names) { + if (!CLINE_SESSION_DIR_RE.test(name)) continue; + const dir = join(root, name); + const meta = await readClineSessionMeta(dir, name); + let mtimeMs = + clineTimestampToMs(meta?.ended_at) || + clineTimestampToMs(meta?.started_at) || + Number(name.split("_")[0]) || + 0; + if (mtimeMs === 0) { + try { + mtimeMs = (await stat(dir)).mtimeMs; + } catch { + /* leave 0 */ + } + } + const cwd = meta?.cwd ?? meta?.workspace_root; + out.push({ + sessionId: name, + cwd, + title: titleFrom(meta?.prompt), + model: meta?.model, + provider: meta?.provider, + projectName: cwd ? encodeFolderName(cwd) : "cline", + mtimeMs, + }); + } + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; +} + +export const getCachedClineSessions = runtimeCache(getClineSessions, 2); + +// ── Dashboard history browser (projects list + project-detail sessions) ── + +/** One `ProjectFolder` per encoded-cwd discovered in cline's session store. */ +export async function getClineProjects(): Promise { + const sessions = await getClineSessions(); + const byName = new Map(); + for (const s of sessions) { + if (!s.cwd) continue; + const existing = byName.get(s.projectName); + if (!existing || s.mtimeMs > existing.latest) { + byName.set(s.projectName, { latest: s.mtimeMs, cwd: s.cwd, name: s.projectName }); + } + } + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["cline"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface ClineProjectByName { + cwd: string | null; + sessions: SessionFile[]; +} + +/** Resolve the cline sessions for a project URL slug (the encoded-cwd folder). */ +export async function getClineSessionsByEncodedName(name: string): Promise { + const sessions = await getClineSessions(); + const matched = sessions.filter((s) => s.cwd && s.projectName === name); + if (matched.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...matched].sort((a, b) => b.mtimeMs - a.mtimeMs); + const cwd = sorted[0].cwd ?? null; + return { + cwd, + sessions: sorted.map((s) => { + const lastModified = new Date(s.mtimeMs); + return { + name: s.title ?? s.sessionId, + path: `cline-session://${s.sessionId}`, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: s.sessionId, + cli: "cline" as const, + }; + }), + }; +} + +export const getCachedClineProjects = runtimeCache(getClineProjects, 2); +export const getCachedClineSessionsByEncodedName = runtimeCache( + (name: string) => getClineSessionsByEncodedName(name), + 2, + { maxSize: 50 }, +); diff --git a/lib/cline-sessions.ts b/lib/cline-sessions.ts new file mode 100644 index 000000000..752e2be18 --- /dev/null +++ b/lib/cline-sessions.ts @@ -0,0 +1,268 @@ +/** + * Cline session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). Cline is the easiest transcript of any integration we + * support, because it already speaks CLAUDE'S OWN content-block schema. Each + * session is a directory under `~/.cline/data/sessions/_/` + * holding exactly two files: + * + * .json session metadata: {session_id, source, pid, started_at, + * ended_at, exit_code, status, interactive, provider, + * model, cwd, workspace_root, prompt} + * .messages.json {version, updated_at, agent, sessionId, origin, + * system_prompt, messages} + * + * and `messages[].content` is an array of the very blocks lib/log-entries.ts + * already models: `{type:"thinking"}`, `{type:"text"}`, + * `{type:"tool_use", id, name, input}`, `{type:"tool_result", tool_use_id, + * name, content}`. So there is no dialect to translate — only tool_result + * pairing, which `clineMessagesToLogEntries` does by `tool_use_id`. + * + * Two things that are NOT free, recorded so nobody assumes otherwise: + * • Cline stores NO per-message timestamp. Every entry in a session therefore + * carries the session's own time, so ordering within a session is array + * order and per-message timing is unrecoverable. (Same limitation as ori.) + * • Tool RESULTS arrive in `role:"user"` messages, Claude-style. A user + * message whose content is entirely tool_result blocks is NOT user prose + * and must not be emitted as one, or every tool call produces a phantom + * user turn in the audit. + * + * Home override: `CLINE_HOME` (the dir containing `data/sessions`, used by + * tests); otherwise `$CLINE_DIR` — which cline itself honours — then `~/.cline`. + */ +import { readFile, readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + type LogEntry, + type UserEntry, + type AssistantEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** Absolute path to cline's home (override with CLINE_HOME, then CLINE_DIR). */ +export function clineHome(): string { + return process.env.CLINE_HOME || process.env.CLINE_DIR || join(homedir(), ".cline"); +} + +/** Absolute path to the directory holding one folder per session. */ +export function clineSessionsDir(): string { + return join(clineHome(), "data", "sessions"); +} + +/** Session directories are `_`, e.g. `1788253271772_bn188`. */ +export const CLINE_SESSION_DIR_RE = /^(\d{10,})_([A-Za-z0-9]+)$/; + +export function clineTimestampToMs(value: string | null | undefined): number { + if (!value) return 0; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +export interface ClineSessionMeta { + session_id?: string; + status?: string; + provider?: string; + model?: string; + cwd?: string; + workspace_root?: string; + prompt?: string; + started_at?: string; + ended_at?: string; + source?: string; + interactive?: boolean; + exit_code?: number; +} + +interface ClineBlock { + type?: string; + text?: string; + thinking?: string; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: unknown; +} + +interface ClineMessage { + role?: string; + content?: string | ClineBlock[]; +} + +/** Flatten a tool_result's `content` into display text. Cline uses an array of + * `{query, result}`-ish objects or plain text blocks depending on the tool, so + * fall back to JSON rather than dropping the result entirely. */ +function toolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content.map((c) => { + if (typeof c === "string") return c; + if (c && typeof c === "object") { + const o = c as Record; + if (typeof o.text === "string") return o.text; + if (typeof o.result === "string") return o.result; + return JSON.stringify(o); + } + return ""; + }); + return parts.filter((p) => p.length > 0).join("\n"); + } + if (content && typeof content === "object") return JSON.stringify(content); + return ""; +} + +/** + * Parse cline's message array into the shared LogEntry[] shape. + * + * PURE — takes already-parsed JSON, so tests need no fixture directory. + */ +export function clineMessagesToLogEntries( + messages: ClineMessage[], + sessionId: string, + timestampMs: number, + source: LogSource = "session", +): LogEntry[] { + const date = new Date(timestampMs); + const timestamp = date.toISOString(); + const entries: LogEntry[] = []; + // tool_use id → the block awaiting its result, so a later user-role + // tool_result attaches onto the assistant turn that made the call. + const pending = new Map(); + + messages.forEach((msg, i) => { + const role = msg.role; + const raw = { uuid: `${sessionId}:${i}`, parentUuid: null } as Record; + const base = baseEntry(raw, timestamp, date, source); + const blocks: ClineBlock[] = Array.isArray(msg.content) ? msg.content : []; + + if (role === "user") { + // Attach any tool results this message carries… + let attached = 0; + for (const b of blocks) { + if (b.type !== "tool_result" || typeof b.tool_use_id !== "string") continue; + attached++; + const target = pending.get(b.tool_use_id); + if (!target) continue; + // No per-message timestamp exists, so duration is unknowable — 0 rather + // than a fabricated span. + target.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: toolResultText(b.content), + durationMs: 0, + durationFormatted: formatDuration(0), + }; + pending.delete(b.tool_use_id); + } + // …and only emit a user turn if there was real prose. A message that is + // nothing but tool results is the tool-result carrier, not a user turn. + const text = + typeof msg.content === "string" + ? msg.content + : blocks + .filter((b) => b.type === "text" && typeof b.text === "string") + .map((b) => b.text as string) + .join(""); + if (text.trim().length > 0) { + entries.push({ ...base, type: "user", message: { role: "user", content: text } } as UserEntry); + } else if (attached === 0 && typeof msg.content === "string") { + entries.push({ ...base, type: "user", message: { role: "user", content: msg.content } } as UserEntry); + } + return; + } + + if (role === "assistant") { + const out: ContentBlock[] = []; + for (const b of blocks) { + if (b.type === "thinking" && typeof b.thinking === "string") { + out.push({ type: "thinking", thinking: b.thinking }); + } else if (b.type === "text" && typeof b.text === "string") { + out.push({ type: "text", text: b.text }); + } else if (b.type === "tool_use" && typeof b.id === "string") { + const block: ToolUseBlock = { + type: "tool_use", + id: b.id, + name: b.name ?? "", + input: b.input ?? {}, + }; + pending.set(b.id, block); + out.push(block); + } + } + if (out.length > 0) { + entries.push({ + ...base, + type: "assistant", + message: { role: "assistant", content: out }, + } as AssistantEntry); + } + } + }); + + return entries; +} + +export interface ClineSessionLog { + entries: LogEntry[]; + cwd?: string; + meta?: ClineSessionMeta; +} + +/** Read a session's metadata sidecar. Returns undefined when absent/unreadable. */ +export async function readClineSessionMeta( + dir: string, + sessionId: string, +): Promise { + try { + return JSON.parse(await readFile(join(dir, `${sessionId}.json`), "utf8")) as ClineSessionMeta; + } catch { + return undefined; + } +} + +/** Load and parse one cline session. Returns null when absent or unreadable. */ +export async function getClineSessionLog(sessionId: string): Promise { + const root = clineSessionsDir(); + let dirs: string[]; + try { + dirs = await readdir(root); + } catch { + return null; + } + // The directory is `_` and the files inside are named for + // the FULL directory name, which is also the session id. + const dirName = dirs.find((d) => d === sessionId); + if (!dirName) return null; + const dir = join(root, dirName); + + let parsed: { messages?: ClineMessage[]; updated_at?: string }; + try { + parsed = JSON.parse(await readFile(join(dir, `${sessionId}.messages.json`), "utf8")) as { + messages?: ClineMessage[]; + updated_at?: string; + }; + } catch { + return null; + } + const meta = await readClineSessionMeta(dir, sessionId); + const ms = + clineTimestampToMs(meta?.started_at) || + clineTimestampToMs(parsed.updated_at) || + Number(sessionId.split("_")[0]) || + 0; + + return { + entries: clineMessagesToLogEntries(parsed.messages ?? [], sessionId, ms), + cwd: meta?.cwd ?? meta?.workspace_root, + meta, + }; +} + +export const getCachedClineSessionLog = runtimeCache(getClineSessionLog, 2); diff --git a/lib/download-session.ts b/lib/download-session.ts index 1eea27f53..36c1191de 100644 --- a/lib/download-session.ts +++ b/lib/download-session.ts @@ -125,6 +125,21 @@ export async function resolveDownloadSource( return path ? { kind: "file", path } : null; } + if (cli === "grok") { + // grok writes a real chat_history.jsonl per session directory — stream it + // verbatim (no synthesis, like Factory). + const { findGrokTranscript } = await import("./grok-sessions"); + const path = findGrokTranscript(sessionId); + return path ? { kind: "file", path } : null; + } + + if (cli === "qwen") { + // qwen writes a real JSONL transcript per session — stream it verbatim. + const { findQwenTranscript } = await import("./qwen-sessions"); + const path = findQwenTranscript(sessionId); + return path ? { kind: "file", path } : null; + } + if (cli === "devin") { // Devin keeps sessions in SQLite (~/.local/share/devin/cli/sessions.db). // Synthesize a JSONL export of the session's raw chat_message rows. @@ -152,6 +167,28 @@ export async function resolveDownloadSource( return { kind: "synthesized", body, contentType: "application/x-ndjson", extension: "jsonl" }; } + if (cli === "ori") { + // Ori keeps the whole conversation in one SQLite column + // (ori_agent_loop_history.prompt). Synthesize a JSONL export from the + // parsed entries — there is no per-session file to stream. + const { getOriSessionLog } = await import("./ori-sessions"); + const result = await getOriSessionLog(sessionId); + if (!result) return null; + const body = result.entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + return { kind: "synthesized", body, contentType: "application/x-ndjson", extension: "jsonl" }; + } + + if (cli === "cline") { + // Cline stores a real per-session messages file, but it is a single JSON + // document rather than JSONL, so stream the parsed entries as JSONL for + // consistency with every other synthesized export. + const { getClineSessionLog } = await import("./cline-sessions"); + const result = await getClineSessionLog(sessionId); + if (!result) return null; + const body = result.entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + return { kind: "synthesized", body, contentType: "application/x-ndjson", extension: "jsonl" }; + } + // Exhaustive — but TypeScript can't always see CliId is exhausted across the // if-chain above, so guard with a runtime fallback. const _exhaustive: never = cli; diff --git a/lib/grok-projects.ts b/lib/grok-projects.ts new file mode 100644 index 000000000..67b078415 --- /dev/null +++ b/lib/grok-projects.ts @@ -0,0 +1,123 @@ +/** + * grok CLI project discovery. + * + * grok stores transcripts at + * `~/.grok/sessions///chat_history.jsonl`. + * + * NOTE the folder encoding differs from every other JSONL store we read: grok + * percent-encodes the cwd (`%2Fhome%2Fuser%2Frepo`) where Claude, Factory and + * Qwen dash-encode it (`-home-user-repo`). + * + * So the on-disk folder name is deliberately NOT used as the project `name`. + * Two reasons, and the first is a hard bug: `name` becomes the URL slug for + * `/project/[name]`, and a percent-encoded name re-encodes to `%252F…` in the + * link, which the route cannot resolve — every grok project 404'd. Second, a + * name no other CLI can produce merges with nothing, so a cwd driven by both + * grok and Claude showed up as two unrelated rows. + * + * Re-encoding the decoded cwd with `encodeFolderName` fixes both at once: the + * slug is URL-safe, and it is byte-identical to the one Claude/Factory/Qwen + * derive for the same cwd, so those rows merge as they should. + */ +import { encodeFolderName, decodeFolderName } from "./paths"; +import { listGrokTranscripts, getGrokSessionLog } from "./grok-sessions"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +/** Returns one ProjectFolder per percent-encoded cwd folder discovered under + * ~/.grok/sessions/. */ +export async function getGrokProjects(): Promise { + let transcripts; + try { + transcripts = listGrokTranscripts(); + } catch (error) { + logWarn("Failed to scan grok sessions:", error); + return []; + } + + const byName = new Map(); + for (const t of transcripts) { + // The URL slug is derived from the real cwd, never from grok's + // percent-encoded folder — see the module header. + const name = encodeFolderName(t.cwd); + const existing = byName.get(name); + if (!existing || t.mtimeMs > existing.latest) { + byName.set(name, { latest: t.mtimeMs, cwd: t.cwd, name }); + } + } + + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["grok"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface GrokProjectByName { + /** Canonical cwd, taken from a session's summary.json `info.cwd`. Null when + * no session could be read. */ + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Look up grok sessions for a project URL slug (the percent-encoded cwd folder + * name). The canonical cwd comes from summary.json; the percent-decode is the + * fallback. + */ +export async function getGrokSessionsByEncodedName(name: string): Promise { + let transcripts; + try { + // Match on the derived slug, since that is what the link carried. + transcripts = listGrokTranscripts().filter((t) => encodeFolderName(t.cwd) === name); + } catch (error) { + logWarn("Failed to scan grok sessions:", error); + return { cwd: null, sessions: [] }; + } + if (transcripts.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...transcripts].sort((a, b) => b.mtimeMs - a.mtimeMs); + + let cwd: string | null = sorted[0].cwd || null; + if (!cwd) { + try { + const log = await getGrokSessionLog(sorted[0].sessionId); + cwd = log?.cwd ?? null; + } catch { + // best-effort — fall back to the decode below + } + } + // `name` is a dash-encoded cwd here, so the shared decoder is the right one. + if (!cwd) cwd = decodeFolderName(name); + + const sessions: SessionFile[] = sorted.map((t) => { + const lastModified = new Date(t.mtimeMs); + return { + name: t.sessionId, + path: t.transcriptPath, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: t.sessionId, + cli: "grok" as const, + }; + }); + return { cwd, sessions }; +} + +export const getCachedGrokProjects = runtimeCache(getGrokProjects, 30); +export const getCachedGrokSessionsByEncodedName = runtimeCache( + (name: string) => getGrokSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/grok-sessions.ts b/lib/grok-sessions.ts new file mode 100644 index 000000000..e241beb9a --- /dev/null +++ b/lib/grok-sessions.ts @@ -0,0 +1,355 @@ +/** + * grok CLI session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). grok stores one DIRECTORY per session at + * `~/.grok/sessions///`, holding + * `chat_history.jsonl` (the turns), `events.jsonl` (a telemetry stream), + * `summary.json` (title + cwd + counts), and assorted lock/state files we + * ignore. Verified live against grok 1.0.3 (1a29d5bc12). + * + * TWO things differ from every other JSONL adapter, and both are load-bearing: + * + * 1. **The cwd folder is PERCENT-encoded** (`%2Fhome%2Fyou%2Frepo`), not + * dash-encoded like Claude/Factory/Qwen — so this module decodes with + * `decodeURIComponent`, NOT `decodeFolderName`. Using the shared helper + * here would silently yield a cwd of literal "%2Fhome%2F…" and every + * project grouping would be wrong. + * + * 2. **`chat_history.jsonl` carries NO timestamps.** Per-event times live in + * `events.jsonl`, which is not 1:1 with the turns. Rather than mis-pair + * them, `grokLinesToLogEntries` takes an explicit `startMs` (the session's + * `created_at` from summary.json) and lays the turns out one millisecond + * apart from there. That keeps ordering exact and the session's absolute + * position on the timeline right, while being honest that per-turn + * wall-clock times are synthesized, not recorded. + * + * Line shapes (OpenAI-ish, discriminated by `type`): + * {type:"system", content:string} — prompt, skipped + * {type:"user", content:[{type:"text",text}], prompt_index?} — see below + * {type:"reasoning", summary:[{type:"summary_text",text}], …} — skipped + * {type:"assistant", content:string, tool_calls:[{id,name,arguments:string}]} + * {type:"tool_result", tool_call_id, content:string} + * + * `tool_calls[].arguments` is a JSON **string**, not an object — it is parsed + * here so tool inputs are inspectable like every other CLI's. + * + * Only user lines carrying `prompt_index` are treated as real user turns. grok + * also writes the environment preamble and `synthetic_reason` reminder + * injections as `user` lines; surfacing those as if the operator typed them + * would make an audit read like the user pasted grok's own boilerplate. + * `prompt_history.jsonl` (a per-cwd sibling) records exactly the same set, + * which is the cross-check that this filter is the right one. + * + * Home override: set `GROK_HOME` (used by tests / to point at a copied + * sessions dir). + */ +import { readFile } from "node:fs/promises"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + parseRawLines, + type LogEntry, + type UserEntry, + type AssistantEntry, + type GenericEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** grok session directories are UUID-named (UUIDv7 in practice). */ +export const GROK_SESSION_ID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** Absolute path to grok's config home (override with GROK_HOME). */ +export function grokHome(): string { + return process.env.GROK_HOME || join(homedir(), ".grok"); +} + +/** Absolute path to the grok sessions root. */ +export function grokSessionsRoot(): string { + return join(grokHome(), "sessions"); +} + +/** + * Decode grok's percent-encoded cwd folder name. Deliberately NOT + * `decodeFolderName` — see the module header. Falls back to the raw name when + * the folder is not valid percent-encoding, so a malformed dir degrades to a + * visible label instead of throwing mid-listing. + */ +export function decodeGrokProjectDir(name: string): string { + try { + return decodeURIComponent(name); + } catch { + return name; + } +} + +// ── Parsing helpers ── + +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +/** Text from a `content` field that is either a string or an array of + * `{type:"text",text}` blocks. */ +function extractText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c) => (isPlainObject(c) && typeof c.text === "string" ? (c.text as string) : "")) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +/** grok serializes tool args as a JSON string; parse it, tolerating garbage. */ +function parseArguments(args: unknown): Record { + if (isPlainObject(args)) return args; + if (typeof args === "string") { + try { + const parsed = JSON.parse(args) as unknown; + if (isPlainObject(parsed)) return parsed; + } catch { + // fall through — keep the raw string so the audit still shows something + } + return { arguments: args }; + } + return {}; +} + +// ── Pure parser: transcript lines → LogEntry[] ── + +/** + * Convert grok `chat_history.jsonl` lines (parsed objects, in file order) into + * `LogEntry[]`. + * + * `startMs` anchors the synthesized timeline — pass the session's `created_at` + * (from summary.json). Turns are laid out 1ms apart in file order. Pure — + * unit-testable with plain line objects. + */ +export function grokLinesToLogEntries( + lines: Record[], + startMs: number = Date.now(), + source: LogSource = "session", +): LogEntry[] { + const entries: LogEntry[] = []; + const toolUseById = new Map(); + const toolUseStartMs = new Map(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isPlainObject(line)) continue; + const type = typeof line.type === "string" ? line.type : ""; + // The system prompt and the model's private reasoning are not turns. + if (type === "system" || type === "reasoning") continue; + + const date = new Date(startMs + i); + const timestamp = date.toISOString(); + const raw: Record = { + uuid: line.id != null ? String(line.id) : `grok-${i}`, + parentUuid: null, + }; + const base = baseEntry(raw, timestamp, date, source); + + if (type === "assistant") { + const blocks: ContentBlock[] = []; + const text = extractText(line.content); + if (text) blocks.push({ type: "text", text }); + const calls = Array.isArray(line.tool_calls) ? line.tool_calls : []; + for (const c of calls) { + if (!isPlainObject(c)) continue; + const id = typeof c.id === "string" ? c.id : `${String(c.name ?? "tool")}-${blocks.length}`; + const name = typeof c.name === "string" ? c.name : "tool"; + const block: ToolUseBlock = { + type: "tool_use", + id, + name, + input: parseArguments(c.arguments), + }; + blocks.push(block); + toolUseById.set(id, block); + toolUseStartMs.set(id, date.getTime()); + } + if (blocks.length === 0) continue; + entries.push({ + type: "assistant", + ...base, + message: { + role: "assistant", + content: blocks, + model: typeof line.model_id === "string" ? line.model_id : undefined, + }, + } satisfies AssistantEntry); + continue; + } + + if (type === "tool_result") { + const callId = typeof line.tool_call_id === "string" ? line.tool_call_id : undefined; + const block = callId ? toolUseById.get(callId) : undefined; + if (block) { + const started = (callId && toolUseStartMs.get(callId)) || date.getTime(); + const durationMs = Math.max(0, date.getTime() - started); + block.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: extractText(line.content), + durationMs, + durationFormatted: formatDuration(durationMs), + }; + continue; + } + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + continue; + } + + if (type === "user") { + // Real operator prompts only — see the module header on prompt_index. + if (line.prompt_index === undefined || line.synthetic_reason !== undefined) continue; + entries.push({ + type: "user", + ...base, + message: { role: "user", content: extractText(line.content) }, + } satisfies UserEntry); + continue; + } + + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + } + + entries.sort((a, b) => a.timestampMs - b.timestampMs); + return entries; +} + +// ── Discovery + file loader ── + +export interface GrokTranscriptFile { + /** Percent-encoded folder name on disk (e.g. "%2Fhome%2Fuser%2Frepo"). */ + projectName: string; + /** Decoded cwd of the project. */ + cwd: string; + sessionId: string; + /** Path to chat_history.jsonl inside the session directory. */ + transcriptPath: string; + mtimeMs: number; + sizeBytes: number; + /** Session title from summary.json, when present. */ + title?: string; + /** created_at from summary.json (ms), used to anchor synthesized timestamps. */ + createdAtMs?: number; +} + +/** Read the optional per-session summary.json sidecar. Never throws. */ +function readSummary(sessionDir: string): { + cwd?: string; + title?: string; + createdAtMs?: number; +} { + try { + const raw = JSON.parse(readFileSync(join(sessionDir, "summary.json"), "utf-8")) as unknown; + if (!isPlainObject(raw)) return {}; + const info = isPlainObject(raw.info) ? raw.info : undefined; + const createdAt = typeof raw.created_at === "string" ? Date.parse(raw.created_at) : NaN; + return { + cwd: info && typeof info.cwd === "string" ? info.cwd : undefined, + title: typeof raw.session_summary === "string" ? raw.session_summary : undefined, + createdAtMs: Number.isNaN(createdAt) ? undefined : createdAt, + }; + } catch { + return {}; + } +} + +/** Enumerate `sessions///chat_history.jsonl` transcripts. */ +export function listGrokTranscripts(): GrokTranscriptFile[] { + const root = grokSessionsRoot(); + const out: GrokTranscriptFile[] = []; + let projectDirs: import("node:fs").Dirent[]; + try { + projectDirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + return out; + } + for (const dir of projectDirs) { + const projectName = dir.name; + const projectPath = join(root, projectName); + let sessionDirs: import("node:fs").Dirent[]; + try { + sessionDirs = readdirSync(projectPath, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + continue; + } + for (const sd of sessionDirs) { + const sessionId = sd.name; + if (!GROK_SESSION_ID_RE.test(sessionId)) continue; + const sessionDir = join(projectPath, sessionId); + const transcriptPath = join(sessionDir, "chat_history.jsonl"); + let st: import("node:fs").Stats; + try { + st = statSync(transcriptPath); + } catch { + continue; // a session dir with no chat history yet + } + const summary = readSummary(sessionDir); + out.push({ + projectName, + // summary.json's info.cwd is canonical; the folder name is the fallback. + cwd: summary.cwd ?? decodeGrokProjectDir(projectName), + sessionId, + transcriptPath, + mtimeMs: st.mtimeMs, + sizeBytes: st.size, + title: summary.title, + createdAtMs: summary.createdAtMs, + }); + } + } + return out; +} + +/** Resolve a session UUID to its chat_history.jsonl path (host-side). Guards + * against traversal by requiring a UUID directory name. Synchronous so the + * hook hot path can call it without awaits. */ +export function findGrokTranscript(sessionId: string): string | null { + if (!GROK_SESSION_ID_RE.test(sessionId)) return null; + for (const t of listGrokTranscripts()) { + if (t.sessionId === sessionId) return t.transcriptPath; + } + return null; +} + +export interface GrokSessionLogData { + entries: LogEntry[]; + rawLines: Record[]; + cwd?: string; + filePath: string; +} + +/** Load and parse one session transcript by UUID. Returns `null` when the file + * is missing/unreadable or the id fails validation. */ +export async function getGrokSessionLog(sessionId: string): Promise { + if (!GROK_SESSION_ID_RE.test(sessionId)) return null; + const meta = listGrokTranscripts().find((t) => t.sessionId === sessionId); + if (!meta) return null; + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return null; + } + const rawLines = parseRawLines(content, "session"); + const entries = grokLinesToLogEntries(rawLines, meta.createdAtMs ?? meta.mtimeMs, "session"); + return { entries, rawLines, cwd: meta.cwd, filePath: meta.transcriptPath }; +} + +export const getCachedGrokSessionLog = runtimeCache( + (sessionId: string) => getGrokSessionLog(sessionId), + 60, + { maxSize: 50 }, +); diff --git a/lib/ori-projects.ts b/lib/ori-projects.ts new file mode 100644 index 000000000..115e86640 --- /dev/null +++ b/lib/ori-projects.ts @@ -0,0 +1,141 @@ +/** + * Ori (OpenRouter's ori) session enumeration — AUDIT-ONLY. + * + * Reads `ori_agent_loop_sessions` from ori's global state DB (see + * lib/ori-sessions.ts for why that is the only real transcript store). Each row + * carries a real `cwd`, so ori sessions group by project like Claude / Factory / + * Devin / Goose rather than landing in one cwd-less bucket the way the Hermes + * gateway does. + * + * `archived = 1` rows are excluded: ori's own `history` command hides them + * behind `--archived`, so surfacing them by default would show a user sessions + * their agent CLI considers put away. `parent_session_id` marks a forked child + * session; it is kept (not filtered) so subagent work still appears. + */ +import { openSqliteReadonly } from "./sqlite-reader"; +import { oriDbPath, oriTimestampToMs } from "./ori-sessions"; +import { encodeFolderName } from "./paths"; +import { runtimeCache } from "./runtime-cache"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { formatDate } from "./format-date"; + +export interface OriSessionRef { + sessionId: string; + cwd?: string; + title?: string; + model?: string; + /** Encoded-cwd folder slug (matches Claude's `-home-user-project` scheme). */ + projectName: string; + /** From `updated_at` — epoch ms. */ + mtimeMs: number; +} + +interface OriSessionRow { + session_id: string; + title: string | null; + cwd: string | null; + model: string | null; + first_prompt: string | null; + archived: number | null; + updated_at: string | null; +} + +/** + * List every ori session. Returns `[]` when the DB is missing or unreadable + * (fail-open — the audit just skips ori). + */ +export async function getOriSessions(): Promise { + const db = await openSqliteReadonly(oriDbPath()); + if (!db) return []; + try { + const rows = db.query( + "SELECT session_id, title, cwd, model, first_prompt, archived, updated_at " + + "FROM ori_agent_loop_sessions ORDER BY updated_at DESC", + ); + return rows + .filter((r) => r.archived !== 1) + .map((r) => { + const cwd = r.cwd ?? undefined; + const title = (r.title && r.title.length > 0 ? r.title : r.first_prompt) ?? undefined; + return { + sessionId: r.session_id, + cwd, + title: title && title.length > 0 ? title : undefined, + model: r.model ?? undefined, + projectName: cwd ? encodeFolderName(cwd) : "ori", + mtimeMs: oriTimestampToMs(r.updated_at), + }; + }); + } catch { + return []; + } finally { + db.close(); + } +} + +export const getCachedOriSessions = runtimeCache(getOriSessions, 2); + +// ── Dashboard history browser (projects list + project-detail sessions) ── + +/** One `ProjectFolder` per encoded-cwd discovered in the ori DB. */ +export async function getOriProjects(): Promise { + const sessions = await getOriSessions(); + const byName = new Map(); + for (const s of sessions) { + if (!s.cwd) continue; + const existing = byName.get(s.projectName); + if (!existing || s.mtimeMs > existing.latest) { + byName.set(s.projectName, { latest: s.mtimeMs, cwd: s.cwd, name: s.projectName }); + } + } + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["ori"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface OriProjectByName { + cwd: string | null; + sessions: SessionFile[]; +} + +/** Resolve the ori sessions for a project URL slug (the encoded-cwd folder). */ +export async function getOriSessionsByEncodedName(name: string): Promise { + const sessions = await getOriSessions(); + const matched = sessions.filter((s) => s.cwd && s.projectName === name); + if (matched.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...matched].sort((a, b) => b.mtimeMs - a.mtimeMs); + const cwd = sorted[0].cwd ?? null; + return { + cwd, + sessions: sorted.map((s) => { + const lastModified = new Date(s.mtimeMs); + return { + name: s.title ?? s.sessionId, + path: `ori-db://${s.sessionId}`, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: s.sessionId, + cli: "ori" as const, + }; + }), + }; +} + +export const getCachedOriProjects = runtimeCache(getOriProjects, 2); +export const getCachedOriSessionsByEncodedName = runtimeCache( + (name: string) => getOriSessionsByEncodedName(name), + 2, + { maxSize: 50 }, +); diff --git a/lib/ori-sessions.ts b/lib/ori-sessions.ts new file mode 100644 index 000000000..281b614e4 --- /dev/null +++ b/lib/ori-sessions.ts @@ -0,0 +1,221 @@ +/** + * Ori (OpenRouter's ori) session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). Where ori keeps a transcript is not obvious, and three + * plausible places are dead ends worth naming so nobody re-derives them: + * + * • `/.ori/logs/sessions//metadata.json` holds ONLY a summary — + * harness, `runIds[{cwd, model, prompt, runId}]`, token `usage`, turn + * counts, timestamps. Verified on a successful tool-using session: no + * messages, no tool calls. + * • `/.ori/logs/code-.jsonl` is runtime LIFECYCLE logging — + * formatted log lines, zero tool records (grepped a full session: 0 hits). + * • The rich `AgentRuntimeEvent` stream exists only transiently on + * `ori code --output jsonl` stdout; nothing persists it. + * + * The real transcript is SQLite, in ori's GLOBAL workspace at + * `~/.ori/global/.ori/state.sqlite` (bare `ori` boots the global workspace, not + * the project's, so one DB holds every project's sessions): + * + * ori_agent_loop_sessions(session_id TEXT PK, title, cwd, model, turns, + * cost_usd REAL, cost_known INT, archived INT, + * first_prompt, parent_session_id, updated_at TEXT ISO) + * ori_agent_loop_history(session_id TEXT PK, prompt TEXT = the whole + * serialized conversation as JSON, revision, updated_at) + * + * `ori_agent_loop_history.prompt` is misnamed — it is not the prompt, it is the + * full conversation: `{"content":[{role, content}, …]}` with Claude-style typed + * blocks on the assistant side (`{type:"reasoning"|"text"}`, + * `{type:"tool-call", id, name, params}`) and results in `role:"tool"` messages + * (`{type:"tool-result", id, name, isFailure, result:[{type:"input_text",text}]}`). + * `oriMessagesToLogEntries` pairs each tool-call with its tool-result by id and + * is a PURE function of the parsed JSON, so it is unit-testable without a DB. + * + * LIMITATION, and it is inherent rather than a shortcut: ori stores NO + * per-message timestamp. Every entry in a session therefore carries the + * session's own `updated_at`, so relative ordering within a session is the + * array order and per-message timing is not recoverable. + * + * Home override: `ORI_HOME` (the dir containing `global/`), or `ORI_DB_PATH` to + * point straight at a state.sqlite. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { openSqliteReadonly } from "./sqlite-reader"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + type LogEntry, + type UserEntry, + type AssistantEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** Absolute path to ori's home (override with ORI_HOME). */ +export function oriHome(): string { + return process.env.ORI_HOME || join(homedir(), ".ori"); +} + +/** Absolute path to ori's state DB (override with ORI_DB_PATH). */ +export function oriDbPath(): string { + return process.env.ORI_DB_PATH || join(oriHome(), "global", ".ori", "state.sqlite"); +} + +/** ori writes ISO-8601 strings; return epoch ms, 0 when absent or unparseable. */ +export function oriTimestampToMs(value: string | null | undefined): number { + if (!value) return 0; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +interface OriBlock { + type?: string; + text?: string; + id?: string; + name?: string; + params?: Record; + isFailure?: boolean; + result?: Array<{ type?: string; text?: string }>; +} + +interface OriMessage { + role?: string; + content?: string | OriBlock[]; +} + +/** Flatten a tool-result's `result` array into display text. */ +function toolResultText(block: OriBlock): string { + if (!Array.isArray(block.result)) return ""; + return block.result + .map((r) => (typeof r.text === "string" ? r.text : "")) + .filter((t) => t.length > 0) + .join("\n"); +} + +/** + * Parse ori's serialized conversation into the shared LogEntry[] shape. + * + * PURE — takes already-parsed JSON so tests need no SQLite. `system` messages + * are dropped: ori's is a static multi-kilobyte agent prompt, identical in + * every session, and carrying it would swamp the audit with boilerplate. + */ +export function oriMessagesToLogEntries( + messages: OriMessage[], + sessionId: string, + timestampMs: number, + source: LogSource = "session", +): LogEntry[] { + const date = new Date(timestampMs); + const timestamp = date.toISOString(); + const entries: LogEntry[] = []; + // tool-call id → the block awaiting its result, so a later `role:"tool"` + // message can attach onto the assistant turn that made the call. + const pending = new Map(); + + messages.forEach((msg, i) => { + const role = msg.role; + if (role === "system") return; + + const raw = { uuid: `${sessionId}:${i}`, parentUuid: null } as Record; + const base = baseEntry(raw, timestamp, date, source); + + if (role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : (msg.content ?? []).map((b) => b.text ?? "").join(""); + entries.push({ ...base, type: "user", message: { role: "user", content: text } } as UserEntry); + return; + } + + if (role === "assistant") { + const blocks: ContentBlock[] = []; + for (const b of Array.isArray(msg.content) ? msg.content : []) { + if (b.type === "reasoning" && typeof b.text === "string") { + blocks.push({ type: "thinking", thinking: b.text }); + } else if (b.type === "text" && typeof b.text === "string") { + blocks.push({ type: "text", text: b.text }); + } else if (b.type === "tool-call" && typeof b.id === "string") { + const block: ToolUseBlock = { + type: "tool_use", + id: b.id, + name: b.name ?? "", + input: b.params ?? {}, + }; + pending.set(b.id, block); + blocks.push(block); + } + } + if (blocks.length > 0) { + entries.push({ ...base, type: "assistant", message: { role: "assistant", content: blocks } } as AssistantEntry); + } + return; + } + + if (role === "tool") { + for (const b of Array.isArray(msg.content) ? msg.content : []) { + if (b.type !== "tool-result" || typeof b.id !== "string") continue; + const target = pending.get(b.id); + if (!target) continue; + // No per-message timestamp exists, so duration is unknowable — 0 rather + // than a fabricated span. + target.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: toolResultText(b), + durationMs: 0, + durationFormatted: formatDuration(0), + }; + pending.delete(b.id); + } + return; + } + }); + + return entries; +} + +export interface OriSessionLog { + entries: LogEntry[]; + cwd?: string; +} + +/** Load and parse one ori session. Returns null when absent or unreadable. */ +export async function getOriSessionLog(sessionId: string): Promise { + const db = await openSqliteReadonly(oriDbPath()); + if (!db) return null; + try { + const rows = db.query<{ prompt: string | null; updated_at: string | null; cwd: string | null }>( + "SELECT h.prompt AS prompt, h.updated_at AS updated_at, s.cwd AS cwd " + + "FROM ori_agent_loop_history h " + + "LEFT JOIN ori_agent_loop_sessions s ON s.session_id = h.session_id " + + "WHERE h.session_id = ?", + [sessionId], + ); + const row = rows[0]; + if (!row || !row.prompt) return null; + let parsed: { content?: OriMessage[] }; + try { + parsed = JSON.parse(row.prompt) as { content?: OriMessage[] }; + } catch { + return null; + } + return { + entries: oriMessagesToLogEntries( + parsed.content ?? [], + sessionId, + oriTimestampToMs(row.updated_at), + ), + cwd: row.cwd ?? undefined, + }; + } catch { + return null; + } finally { + db.close(); + } +} + +export const getCachedOriSessionLog = runtimeCache(getOriSessionLog, 2); diff --git a/lib/projects.ts b/lib/projects.ts index 3471be723..035cfdfcc 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -16,7 +16,7 @@ import { formatDate } from "./format-date"; export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/; -export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose"; +export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose" | "grok" | "qwen" | "ori" | "cline"; export interface ProjectFolder { name: string; @@ -171,6 +171,10 @@ export async function getProjectFolders(): Promise { { getDevinProjects }, { getAntigravityProjects }, { getGooseProjects }, + { getGrokProjects }, + { getQwenProjects }, + { getOriProjects }, + { getClineProjects }, ] = await Promise.all([ import("./codex-projects"), import("./copilot-projects"), @@ -183,8 +187,12 @@ export async function getProjectFolders(): Promise { import("./devin-projects"), import("./antigravity-projects"), import("./goose-projects"), + import("./grok-projects"), + import("./qwen-projects"), + import("./ori-projects"), + import("./cline-projects"), ]); - const [claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose] = await Promise.all([ + const [claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen, ori, cline] = await Promise.all([ getClaudeProjectFolders(), getCodexProjects().catch((error) => { logError("Error reading Codex projects:", error); @@ -230,8 +238,24 @@ export async function getProjectFolders(): Promise { logError("Error reading Goose projects:", error); return [] as ProjectFolder[]; }), + getGrokProjects().catch((error) => { + logError("Error reading grok projects:", error); + return [] as ProjectFolder[]; + }), + getQwenProjects().catch((error) => { + logError("Error reading Qwen projects:", error); + return [] as ProjectFolder[]; + }), + getOriProjects().catch((error) => { + logError("Error reading Ori projects:", error); + return [] as ProjectFolder[]; + }), + getClineProjects().catch((error) => { + logError("Error reading Cline projects:", error); + return [] as ProjectFolder[]; + }), ]); - return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose); + return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose, grok, qwen, ori, cline); } /** diff --git a/lib/qwen-projects.ts b/lib/qwen-projects.ts new file mode 100644 index 000000000..4642a848b --- /dev/null +++ b/lib/qwen-projects.ts @@ -0,0 +1,106 @@ +/** + * Qwen Code project discovery. + * + * qwen stores transcripts at + * `~/.qwen/projects//chats/.jsonl`, using the same + * Claude-style encoded-cwd folder names (`-home-user-project`) as Claude Code. + * The encoded folder doubles as the URL slug for `/project/[name]`, so a cwd + * present in both stores naturally produces the same `name` and merges on the + * Claude side (see `mergeProjectFolders` in lib/projects.ts). + */ +import { decodeFolderName } from "./paths"; +import { listQwenTranscripts, getQwenSessionLog } from "./qwen-sessions"; +import type { ProjectFolder, SessionFile } from "./projects"; +import { runtimeCache } from "./runtime-cache"; +import { formatDate } from "./format-date"; +import { logWarn } from "./logger"; + +/** Returns one ProjectFolder per encoded-cwd folder discovered under + * ~/.qwen/projects/. */ +export async function getQwenProjects(): Promise { + let transcripts; + try { + transcripts = listQwenTranscripts(); + } catch (error) { + logWarn("Failed to scan Qwen sessions:", error); + return []; + } + + const byName = new Map(); + for (const t of transcripts) { + const existing = byName.get(t.projectName); + if (!existing || t.mtimeMs > existing.latest) { + byName.set(t.projectName, { latest: t.mtimeMs, cwd: t.cwd, name: t.projectName }); + } + } + + const folders: ProjectFolder[] = []; + for (const { name, cwd, latest } of byName.values()) { + const lastModified = new Date(latest); + folders.push({ + name, + path: cwd, + isDirectory: true, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + cli: ["qwen"], + }); + } + folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return folders; +} + +export interface QwenProjectByName { + /** Canonical cwd recovered from a session line (the folder decode is lossy). + * Null when no session could be read. */ + cwd: string | null; + sessions: SessionFile[]; +} + +/** + * Look up Qwen sessions for a project URL slug (the encoded-cwd folder name). + * qwen names its folders with the same encoding Claude uses, so the slug + * matches the folder directly. The canonical cwd is recovered from the newest + * session's own `cwd` field (folder decode is lossy for cwds containing `-`). + */ +export async function getQwenSessionsByEncodedName(name: string): Promise { + let transcripts; + try { + transcripts = listQwenTranscripts().filter((t) => t.projectName === name); + } catch (error) { + logWarn("Failed to scan Qwen sessions:", error); + return { cwd: null, sessions: [] }; + } + if (transcripts.length === 0) return { cwd: null, sessions: [] }; + + const sorted = [...transcripts].sort((a, b) => b.mtimeMs - a.mtimeMs); + + let cwd: string | null = null; + try { + const log = await getQwenSessionLog(sorted[0].sessionId); + cwd = log?.cwd ?? null; + } catch { + // best-effort — fall back to the lossy decode below + } + if (!cwd) cwd = decodeFolderName(name); + + const sessions: SessionFile[] = sorted.map((t) => { + const lastModified = new Date(t.mtimeMs); + return { + name: t.sessionId, + path: t.transcriptPath, + lastModified, + lastModifiedFormatted: formatDate(lastModified), + sessionId: t.sessionId, + cli: "qwen" as const, + }; + }); + return { cwd, sessions }; +} + +export const getCachedQwenProjects = runtimeCache(getQwenProjects, 30); +export const getCachedQwenSessionsByEncodedName = runtimeCache( + (name: string) => getQwenSessionsByEncodedName(name), + 30, + { maxSize: 50 }, +); diff --git a/lib/qwen-sessions.ts b/lib/qwen-sessions.ts new file mode 100644 index 000000000..57a8e47b3 --- /dev/null +++ b/lib/qwen-sessions.ts @@ -0,0 +1,313 @@ +/** + * Qwen Code session transcript loader + parser. + * + * AUDIT-ONLY (Pillar 2). qwen writes one JSONL transcript per session at + * `~/.qwen/projects//chats/.jsonl` — Claude-style + * encoded-cwd folders (e.g. `-home-chetan-project`), but note the extra + * `chats/` level, which Claude/Factory do not have. Verified live against + * @qwen-code/qwen-code 0.21.12. + * + * Every line carries `{uuid, parentUuid, sessionId, cwd, timestamp, version, + * type}`. `type` is one of: + * "user" — message.parts, role "user" + * "assistant" — message.parts, role "model" (Gemini's name for assistant) + * "tool_result" — message.parts + a `toolCallResult` sidecar + * "system" — bookkeeping (subtype/systemPayload); skipped + * + * The message body is **Gemini-shaped, not Claude-shaped**: `message.parts[]` + * holds `{text}`, `{functionCall:{id,name,args}}`, and + * `{functionResponse:{id,name,response}}` — so this is NOT a clone of + * lib/factory-sessions.ts despite the similar on-disk layout. `functionCall` is + * paired with its later `functionResponse` by id (mirroring the tool_use / + * tool_result pairing elsewhere). `qwenLinesToLogEntries` is PURE, so it is + * unit-testable with plain line objects. + * + * Home override: set `QWEN_HOME` (used by tests / to point at a copied + * projects dir). + */ +import { readFile } from "node:fs/promises"; +import { readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { decodeFolderName } from "./paths"; +import { runtimeCache } from "./runtime-cache"; +import { + baseEntry, + formatTimestamp, + parseRawLines, + type LogEntry, + type UserEntry, + type AssistantEntry, + type GenericEntry, + type ContentBlock, + type ToolUseBlock, + type LogSource, +} from "./log-entries"; +import { formatDuration } from "./format-duration"; + +/** Qwen sessions are stored under UUID filenames. */ +export const QWEN_SESSION_ID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** Absolute path to Qwen's config home (override with QWEN_HOME). */ +export function qwenHome(): string { + return process.env.QWEN_HOME || join(homedir(), ".qwen"); +} + +/** Absolute path to the Qwen projects root. */ +export function qwenProjectsRoot(): string { + return join(qwenHome(), "projects"); +} + +// ── Parsing helpers ── + +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +/** Flatten a Gemini `response` payload (or any tool output) to display text. */ +function extractResponseText(response: unknown): string { + if (typeof response === "string") return response; + if (!isPlainObject(response)) return ""; + for (const key of ["output", "result", "content", "error"]) { + const v = response[key]; + if (typeof v === "string" && v.length > 0) return v; + } + return JSON.stringify(response); +} + +function toDate(value: unknown, fallbackMs: number): Date { + if (typeof value === "number" && Number.isFinite(value)) { + if (value > 1e12) return new Date(value); + if (value > 1e9) return new Date(value * 1000); + } + if (typeof value === "string") { + const ms = Date.parse(value); + if (!Number.isNaN(ms)) return new Date(ms); + } + return new Date(fallbackMs); +} + +// ── Pure parser: transcript lines → LogEntry[] ── + +/** + * Convert Qwen transcript JSONL lines (parsed objects, in file order) into + * `LogEntry[]`. `system` lines are skipped. Assistant `functionCall` parts are + * paired with their later `functionResponse` by call id. Pure — unit-testable + * with plain line objects. + */ +export function qwenLinesToLogEntries( + lines: Record[], + source: LogSource = "session", +): LogEntry[] { + const entries: LogEntry[] = []; + const toolUseById = new Map(); + const toolUseStartMs = new Map(); + const baseMs = Date.now(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isPlainObject(line)) continue; + const type = typeof line.type === "string" ? line.type : ""; + if (type === "system") continue; // subtype/systemPayload bookkeeping + + const m = isPlainObject(line.message) ? line.message : undefined; + if (!m) continue; + const parts = Array.isArray(m.parts) ? m.parts : []; + + const date = toDate(line.timestamp, baseMs + i); + const timestamp = date.toISOString(); + const raw: Record = { + uuid: line.uuid != null ? String(line.uuid) : `qwen-${i}`, + parentUuid: line.parentUuid != null ? String(line.parentUuid) : null, + }; + const base = baseEntry(raw, timestamp, date, source); + + // Assistant turn: text parts and/or functionCall parts. Qwen names the + // assistant role "model" (Gemini lineage); accept both spellings. + if (type === "assistant" || m.role === "model" || m.role === "assistant") { + const blocks: ContentBlock[] = []; + for (const p of parts) { + if (!isPlainObject(p)) continue; + if (typeof p.text === "string" && p.text.length > 0) { + blocks.push({ type: "text", text: p.text }); + continue; + } + const fc = isPlainObject(p.functionCall) ? p.functionCall : undefined; + if (fc) { + const id = typeof fc.id === "string" ? fc.id : `${String(fc.name ?? "tool")}-${blocks.length}`; + const name = typeof fc.name === "string" ? fc.name : "tool"; + const input = isPlainObject(fc.args) ? fc.args : {}; + const block: ToolUseBlock = { type: "tool_use", id, name, input }; + blocks.push(block); + toolUseById.set(id, block); + toolUseStartMs.set(id, date.getTime()); + } + } + if (blocks.length === 0) continue; // empty / failed assistant turn + entries.push({ + type: "assistant", + ...base, + message: { + role: "assistant", + content: blocks, + model: typeof line.model === "string" ? line.model : undefined, + }, + } satisfies AssistantEntry); + continue; + } + + // tool_result / user: functionResponse parts attach to their call; plain + // text parts become a user turn. + let attachedAny = false; + const textParts: string[] = []; + for (const p of parts) { + if (!isPlainObject(p)) continue; + const fr = isPlainObject(p.functionResponse) ? p.functionResponse : undefined; + if (fr) { + const callId = typeof fr.id === "string" ? fr.id : undefined; + const block = callId ? toolUseById.get(callId) : undefined; + if (block) { + const startMs = (callId && toolUseStartMs.get(callId)) || date.getTime(); + const durationMs = Math.max(0, date.getTime() - startMs); + // Prefer the sidecar's rendered text — it is what the TUI showed — + // and fall back to flattening the raw functionResponse payload. + const sidecar = isPlainObject(line.toolCallResult) ? line.toolCallResult : undefined; + const display = + sidecar && typeof sidecar.resultDisplay === "string" && sidecar.resultDisplay.length > 0 + ? sidecar.resultDisplay + : extractResponseText(fr.response); + block.result = { + timestamp, + timestampFormatted: formatTimestamp(date), + content: display, + durationMs, + durationFormatted: formatDuration(durationMs), + }; + attachedAny = true; + continue; + } + } + if (typeof p.text === "string" && p.text.length > 0) textParts.push(p.text); + } + if (textParts.length > 0) { + entries.push({ + type: "user", + ...base, + message: { role: "user", content: textParts.join("\n") }, + } satisfies UserEntry); + } else if (!attachedAny) { + entries.push({ type: "system", ...base, raw } satisfies GenericEntry); + } + } + + entries.sort((a, b) => a.timestampMs - b.timestampMs); + return entries; +} + +// ── Discovery + file loader ── + +export interface QwenTranscriptFile { + /** Encoded folder name on disk (e.g. "-home-user-project"). */ + projectName: string; + /** Decoded cwd of the project (lossy; canonical cwd lives on every line). */ + cwd: string; + sessionId: string; + transcriptPath: string; + mtimeMs: number; + sizeBytes: number; +} + +/** Enumerate `projects//chats/.jsonl` transcripts. */ +export function listQwenTranscripts(): QwenTranscriptFile[] { + const root = qwenProjectsRoot(); + const out: QwenTranscriptFile[] = []; + let projectDirs: import("node:fs").Dirent[]; + try { + projectDirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); + } catch { + return out; + } + for (const dir of projectDirs) { + const projectName = dir.name; + const cwd = decodeFolderName(projectName); + // Transcripts live one level deeper than Claude/Factory, under `chats/`. + const chatsPath = join(root, projectName, "chats"); + let files: string[]; + try { + files = readdirSync(chatsPath); + } catch { + continue; + } + for (const file of files) { + if (!file.endsWith(".jsonl")) continue; + const sessionId = file.slice(0, -".jsonl".length); + if (!QWEN_SESSION_ID_RE.test(sessionId)) continue; + const transcriptPath = join(chatsPath, file); + try { + const st = statSync(transcriptPath); + out.push({ + projectName, + cwd, + sessionId, + transcriptPath, + mtimeMs: st.mtimeMs, + sizeBytes: st.size, + }); + } catch { + // skip unreadable + } + } + } + return out; +} + +/** Resolve a session UUID to its on-disk transcript path (host-side). Guards + * against traversal by requiring a UUID filename. Shared by the audit adapter, + * the hook transcript resolver, and download-session. Synchronous so the hook + * hot path can call it without awaits. */ +export function findQwenTranscript(sessionId: string): string | null { + if (!QWEN_SESSION_ID_RE.test(sessionId)) return null; + for (const t of listQwenTranscripts()) { + if (t.sessionId === sessionId) return t.transcriptPath; + } + return null; +} + +export interface QwenSessionLogData { + entries: LogEntry[]; + rawLines: Record[]; + cwd?: string; + filePath: string; +} + +/** Load and parse one session transcript by UUID. Returns `null` when the file + * is missing/unreadable or the id fails validation. */ +export async function getQwenSessionLog(sessionId: string): Promise { + const filePath = findQwenTranscript(sessionId); + if (!filePath) return null; + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return null; + } + const rawLines = parseRawLines(content, "session"); + const entries = qwenLinesToLogEntries(rawLines, "session"); + // Every line carries the real cwd; take the first one rather than trusting + // the lossy decoded folder name. + let cwd: string | undefined; + for (const line of rawLines) { + if (isPlainObject(line) && typeof line.cwd === "string" && line.cwd.length > 0) { + cwd = line.cwd; + break; + } + } + return { entries, rawLines, cwd, filePath }; +} + +export const getCachedQwenSessionLog = runtimeCache( + (sessionId: string) => getQwenSessionLog(sessionId), + 60, + { maxSize: 50 }, +); diff --git a/lib/session-stores.ts b/lib/session-stores.ts new file mode 100644 index 000000000..9433b2e25 --- /dev/null +++ b/lib/session-stores.ts @@ -0,0 +1,65 @@ +/** + * Every non-Claude session store the dashboard can read, in the order it tries + * them. Lives here rather than in the session page so a test can assert its + * COVERAGE against INTEGRATION_TYPES — see __tests__/lib/session-stores.test.ts. + */ +import type { LogEntry } from "./log-entries"; +import { getCachedCodexSessionLog } from "./codex-sessions"; +import { getCachedCopilotSessionLog } from "./copilot-sessions"; +import { getCachedCursorSessionLog } from "./cursor-sessions"; +import { getCachedOpenCodeSessionLog } from "./opencode-sessions"; +import { getCachedPiSessionLog } from "./pi-sessions"; +import { getCachedHermesSessionLog } from "./hermes-sessions"; +import { getCachedOpenClawSessionLog } from "./openclaw-sessions"; +import { getCachedFactorySessionLog } from "./factory-sessions"; +import { getCachedDevinSessionLog } from "./devin-sessions"; +import { getCachedAntigravitySessionLog } from "./antigravity-sessions"; +import { getCachedGooseSessionLog } from "./goose-sessions"; +import { getCachedGrokSessionLog } from "./grok-sessions"; +import { getCachedQwenSessionLog } from "./qwen-sessions"; +import { getCachedOriSessionLog } from "./ori-sessions"; +import { getCachedClineSessionLog } from "./cline-sessions"; + +export type SessionCli = + | "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" + | "openclaw" | "factory" | "devin" | "antigravity" | "goose" | "grok" + | "qwen" | "ori" | "cline"; + +export interface ExternalSessionLog { + entries: LogEntry[]; + rawLines?: Record[] | null; + cwd?: string; +} + +/** + * Every non-Claude session store, tried in order. + * + * This is a TABLE and not the nested if/else chain it replaces, for a reason + * worth keeping: that chain reached TWELVE levels deep, and four integrations + * (grok, qwen, ori, cline) were added without anyone extending it. Because the + * innermost `else` set "Session log file not found." and `cli` defaulted to + * "claude", every session from those four rendered with a CLAUDE CODE badge and + * no log — the store was never consulted. A missing row here is now one line + * missing from a list, not a level missing from a pyramid. + */ +export const EXTERNAL_SESSION_STORES: ReadonlyArray<{ + cli: Exclude; + label: string; + load: (sessionId: string) => Promise; +}> = [ + { cli: "codex", label: "OpenAI Codex", load: getCachedCodexSessionLog }, + { cli: "copilot", label: "GitHub Copilot", load: getCachedCopilotSessionLog }, + { cli: "cursor", label: "Cursor Agent", load: getCachedCursorSessionLog }, + { cli: "opencode", label: "OpenCode", load: getCachedOpenCodeSessionLog }, + { cli: "pi", label: "Pi", load: getCachedPiSessionLog }, + { cli: "hermes", label: "Hermes", load: getCachedHermesSessionLog }, + { cli: "openclaw", label: "OpenClaw", load: getCachedOpenClawSessionLog }, + { cli: "factory", label: "Factory Droid", load: getCachedFactorySessionLog }, + { cli: "devin", label: "Devin CLI", load: getCachedDevinSessionLog }, + { cli: "antigravity", label: "Antigravity CLI", load: getCachedAntigravitySessionLog }, + { cli: "goose", label: "Goose", load: getCachedGooseSessionLog }, + { cli: "grok", label: "grok CLI", load: getCachedGrokSessionLog }, + { cli: "qwen", label: "Qwen Code", load: getCachedQwenSessionLog }, + { cli: "ori", label: "Ori", load: getCachedOriSessionLog }, + { cli: "cline", label: "Cline", load: getCachedClineSessionLog }, +]; diff --git a/package.json b/package.json index f5b4d75ac..16d310087 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "failproofai", - "version": "1.0.4-beta.0", - "description": "Observability and enforcement for AI agent harnesses. 39 built-in policies hooked into 12 of them — Claude Code, Codex, Cursor, Hermes, OpenClaw and more — blocking the tool call before it runs. Local dashboard included, no account needed.", + "version": "1.0.4-beta.1", + "description": "Observability and enforcement for AI agent harnesses. 39 built-in policies hooked into 16 of them \u2014 Claude Code, Codex, Cursor, Hermes, OpenClaw and more \u2014 blocking the tool call before it runs. Local dashboard included, no account needed.", "bin": { "failproofai": "./dist/cli.mjs", "failproofaid": "./bin/failproofaid-shim.mjs" @@ -117,6 +117,7 @@ "vite": "8.0.16", "undici": "7.29.0", "brace-expansion": "5.0.9", - "sharp": "0.35.0" + "sharp": "0.35.0", + "browserslist": "4.28.8" } } diff --git a/src/audit/cli-adapters/cline.ts b/src/audit/cli-adapters/cline.ts new file mode 100644 index 000000000..c267755c5 --- /dev/null +++ b/src/audit/cli-adapters/cline.ts @@ -0,0 +1,50 @@ +/** + * Cline transcript adapter — AUDIT-ONLY (Pillar 2). + * + * The thinnest adapter we have, because cline already stores Claude's own + * content blocks: lib/cline-projects.ts enumerates the per-session directories + * (each carrying a real `cwd`, so `audit --project ` filters work as they + * do for Devin, Goose and ori), lib/cline-sessions.ts pairs tool_result back + * onto tool_use, and `logEntriesToEvents` does the rest unchanged. + */ +import { getClineSessions } from "../../../lib/cline-projects"; +import { getClineSessionLog } from "../../../lib/cline-sessions"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listClineTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const sessions = await getClineSessions(); + const out: TranscriptMetadata[] = []; + for (const s of sessions) { + if (s.mtimeMs < sinceMs) continue; + if (projectFilter && (!s.cwd || !projectFilter.has(s.cwd))) continue; + out.push({ + cli: "cline", + projectName: s.projectName, + sessionId: s.sessionId, + transcriptPath: `cline-session://${s.sessionId}`, + mtimeMs: s.mtimeMs, + // ended_at/started_at advance per session, so (mtime) is a real cache key. + sizeBytes: 0, + }); + } + return out; +} + +export async function streamClineEvents( + meta: TranscriptMetadata, +): Promise { + const log = await getClineSessionLog(meta.sessionId); + if (!log) return []; + return logEntriesToEvents(log.entries, { + cli: "cline", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd: log.cwd ?? "", + }); +} diff --git a/src/audit/cli-adapters/grok.ts b/src/audit/cli-adapters/grok.ts new file mode 100644 index 000000000..ee5d3c0e0 --- /dev/null +++ b/src/audit/cli-adapters/grok.ts @@ -0,0 +1,70 @@ +/** + * grok CLI transcript adapter — AUDIT (Pillar 2). + * + * grok writes one session DIRECTORY at + * `~/.grok/sessions///`, whose + * `chat_history.jsonl` holds the turns (verified live against grok 1.0.3). + * lib/grok-sessions.ts enumerates and parses them. + * + * `chat_history.jsonl` has no per-message timestamps, so the parser anchors a + * synthesized timeline on the session's `created_at` (from summary.json) — + * `listGrokTranscripts` already resolved that, so it is passed straight + * through here rather than re-read. + */ +import { readFile } from "node:fs/promises"; +import { listGrokTranscripts, grokLinesToLogEntries } from "../../../lib/grok-sessions"; +import { parseRawLines } from "../../../lib/log-entries"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listGrokTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const out: TranscriptMetadata[] = []; + + for (const t of listGrokTranscripts()) { + if (t.mtimeMs < sinceMs) continue; + // `audit --project ` filters on the real cwd (summary.json's info.cwd + // when present, else the percent-decoded folder name). + if (projectFilter && !projectFilter.has(t.cwd)) continue; + out.push({ + cli: "grok", + projectName: t.projectName, + sessionId: t.sessionId, + transcriptPath: t.transcriptPath, + mtimeMs: t.mtimeMs, + sizeBytes: t.sizeBytes, + }); + } + return out; +} + +export async function streamGrokEvents(meta: TranscriptMetadata): Promise { + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return []; + } + + // Recover this session's cwd + created_at from the enumeration (both come + // from summary.json, which streamEvents is not otherwise given). + const known = listGrokTranscripts().find((t) => t.sessionId === meta.sessionId); + + const rawLines = parseRawLines(content, "session"); + const entries = grokLinesToLogEntries( + rawLines, + known?.createdAtMs ?? meta.mtimeMs, + "session", + ); + + return logEntriesToEvents(entries, { + cli: "grok", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd: known?.cwd ?? "", + }); +} diff --git a/src/audit/cli-adapters/index.ts b/src/audit/cli-adapters/index.ts index 6df583856..333e94405 100644 --- a/src/audit/cli-adapters/index.ts +++ b/src/audit/cli-adapters/index.ts @@ -23,6 +23,10 @@ import { listFactoryTranscriptMetadata, streamFactoryEvents } from "./factory"; import { listAntigravityTranscriptMetadata, streamAntigravityEvents } from "./antigravity"; import { listDevinTranscriptMetadata, streamDevinEvents } from "./devin"; import { listGooseTranscriptMetadata, streamGooseEvents } from "./goose"; +import { listGrokTranscriptMetadata, streamGrokEvents } from "./grok"; +import { listQwenTranscriptMetadata, streamQwenEvents } from "./qwen"; +import { listOriTranscriptMetadata, streamOriEvents } from "./ori"; +import { listClineTranscriptMetadata, streamClineEvents } from "./cline"; export type { ListOpts }; @@ -123,6 +127,26 @@ export const ADAPTERS: Record = { listTranscripts: listGooseTranscriptMetadata, streamEvents: streamGooseEvents, }, + grok: { + cli: "grok", + listTranscripts: listGrokTranscriptMetadata, + streamEvents: streamGrokEvents, + }, + qwen: { + cli: "qwen", + listTranscripts: listQwenTranscriptMetadata, + streamEvents: streamQwenEvents, + }, + ori: { + cli: "ori", + listTranscripts: listOriTranscriptMetadata, + streamEvents: streamOriEvents, + }, + cline: { + cli: "cline", + listTranscripts: listClineTranscriptMetadata, + streamEvents: streamClineEvents, + }, }; export function getAdapter(cli: IntegrationType): CliAdapter { diff --git a/src/audit/cli-adapters/ori.ts b/src/audit/cli-adapters/ori.ts new file mode 100644 index 000000000..dc086decd --- /dev/null +++ b/src/audit/cli-adapters/ori.ts @@ -0,0 +1,55 @@ +/** + * Ori (OpenRouter's ori) transcript adapter — AUDIT-ONLY (Pillar 2). + * + * Covers ori's OWN agent (bare `ori` / `ori code`). Sessions launched through + * ori's LAUNCHER (`ori claude`, `ori codex`, …) are recorded by the launched + * CLI in its own store and are picked up by that CLI's adapter, not this one. + * + * ori keeps the whole conversation in one SQLite DB in its global workspace — + * lib/ori-projects.ts enumerates `ori_agent_loop_sessions`, lib/ori-sessions.ts + * parses each session's serialized message array — producing the same + * LogEntry[] shape the other adapters do, so `logEntriesToEvents` handles the + * rest. Each session carries a real `cwd`, so `audit --project ` filters + * work as they do for Devin and Goose. + */ +import { getOriSessions } from "../../../lib/ori-projects"; +import { getOriSessionLog } from "../../../lib/ori-sessions"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listOriTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const sessions = await getOriSessions(); + const out: TranscriptMetadata[] = []; + for (const s of sessions) { + if (s.mtimeMs < sinceMs) continue; + if (projectFilter && (!s.cwd || !projectFilter.has(s.cwd))) continue; + out.push({ + cli: "ori", + projectName: s.projectName, + sessionId: s.sessionId, + transcriptPath: `ori-db://${s.sessionId}`, + mtimeMs: s.mtimeMs, + // updated_at advances on each turn, so (mtime) forms a real cache key. + sizeBytes: 0, + }); + } + return out; +} + +export async function streamOriEvents( + meta: TranscriptMetadata, +): Promise { + const log = await getOriSessionLog(meta.sessionId); + if (!log) return []; + return logEntriesToEvents(log.entries, { + cli: "ori", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd: log.cwd ?? "", + }); +} diff --git a/src/audit/cli-adapters/qwen.ts b/src/audit/cli-adapters/qwen.ts new file mode 100644 index 000000000..ead15f904 --- /dev/null +++ b/src/audit/cli-adapters/qwen.ts @@ -0,0 +1,71 @@ +/** + * Qwen Code transcript adapter — AUDIT (Pillar 2). + * + * qwen writes real JSONL transcripts at + * `~/.qwen/projects//chats/.jsonl` (Claude-style + * encoded-cwd folders, one level deeper than Claude's; verified live against + * qwen-code 0.21.12). lib/qwen-sessions.ts enumerates and parses them — the + * message bodies are Gemini-shaped `parts[]`, not Claude content blocks — into + * the shared LogEntry[] shape, so `logEntriesToEvents` handles the rest. + */ +import { readFile } from "node:fs/promises"; +import { listQwenTranscripts, qwenLinesToLogEntries } from "../../../lib/qwen-sessions"; +import { parseRawLines } from "../../../lib/log-entries"; +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; +import { logEntriesToEvents } from "./shared"; + +export async function listQwenTranscriptMetadata( + opts: ListOpts = {}, +): Promise { + const projectFilter = opts.projects ? new Set(opts.projects) : null; + const sinceMs = opts.sinceMs ?? 0; + const out: TranscriptMetadata[] = []; + + for (const t of listQwenTranscripts()) { + if (t.mtimeMs < sinceMs) continue; + // `audit --project ` filters on the decoded working directory. + if (projectFilter && !projectFilter.has(t.cwd)) continue; + out.push({ + cli: "qwen", + projectName: t.projectName, + sessionId: t.sessionId, + transcriptPath: t.transcriptPath, + mtimeMs: t.mtimeMs, + sizeBytes: t.sizeBytes, + }); + } + return out; +} + +export async function streamQwenEvents(meta: TranscriptMetadata): Promise { + let content: string; + try { + content = await readFile(meta.transcriptPath, "utf-8"); + } catch { + return []; + } + + const rawLines = parseRawLines(content, "session"); + const entries = qwenLinesToLogEntries(rawLines, "session"); + + // Every qwen line carries the real cwd — take the first rather than + // re-decoding the (lossy) folder name. + let cwd = ""; + for (const line of rawLines) { + if (line && typeof line === "object") { + const c = (line as Record).cwd; + if (typeof c === "string" && c.length > 0) { + cwd = c; + break; + } + } + } + + return logEntriesToEvents(entries, { + cli: "qwen", + sessionId: meta.sessionId, + transcriptPath: meta.transcriptPath, + cwd, + }); +} diff --git a/src/hooks/batch-expand.ts b/src/hooks/batch-expand.ts new file mode 100644 index 000000000..fc2f31d42 --- /dev/null +++ b/src/hooks/batch-expand.ts @@ -0,0 +1,380 @@ +/** + * Expand a CLI's BATCH tool call into per-element canonical scalar inputs. + * + * failproofai's builtins read SCALARS — `tool_input.command`, `.file_path`, + * `.pattern`, `.old_string`. Cline delivers ARRAYS: `run_commands{commands[]}`, + * `read_files{files[]}`, `search_codebase{queries[]}`, plus one multi-file blob + * in `apply_patch{input}`. With only a key rename, `block-sudo`, + * `block-env-files`, `block-secrets-write`, `block-force-push` and + * `block-read-outside-cwd` all read `undefined` and ALLOW SILENTLY — the + * inert-hook failure this repo has already shipped twice (grok, and our own + * Claude hooks running inside grok). + * + * Joining the array into one string is NOT a fix, and the reason is specific: + * `SECRET_FILE_RE` is `/\.(?:pem|key)$/`. Under any join only the LAST element + * can ever match, so a `.pem` at `files[0]` rides straight through. That is why + * this expands and the evaluator runs the policy set once per element. + * + * PURE: no evaluator import, no fs, no policy registry — so the audit path can + * call it without dragging the policy engine along. + * + * CONTAINMENT: returns null for every CLI except cline and every tool except + * cline's four containers. `null` means "take the existing single-shot path, + * byte-for-byte unchanged", so this cannot regress the other 15 integrations. + */ +import type { IntegrationType } from "./types"; +import { + ENV_FILE_PATH_RE, + SECRET_FILE_RE, + SECRET_FILE_ID_RSA_RE, + SECRET_FILE_CREDENTIALS_RE, +} from "./risk-patterns"; + +export interface FanoutElement { + /** Canonical SCALAR tool input: {command} | {file_path,…} | {pattern} | + * {file_path, old_string, new_string, patch}. */ + input: Record; + /** Short human locator; goes into the deny message and the activity row. */ + label: string; + index: number; + /** True when this element is a COLLAPSE of several that the cap or the + * wall-clock budget refused to run apart. A weaker guarantee we state + * explicitly, never a silent skip. */ + degraded?: boolean; +} + +export interface BatchExpansion { + /** Canonical tool name (Bash | Read | Grep | Edit). */ + tool: string; + elements: FanoutElement[]; + /** The un-expanded original, so a batch-aware policy still sees the whole call. */ + raw: Record; +} + +/** + * The separator used wherever a command list still has to collapse to one + * string. `" &&\n"` is DERIVED, not chosen: + * • `"\n"` alone silently disables `READ_LIKE_CMDS` — its boundary + * alternation `(?:^|;|&&|\|\||\|)` contains no newline, so a batched + * `cat /etc/passwd` stops looking read-like and `block-read-outside-cwd` + * never inspects it. + * • `" && "` alone manufactures a FALSE deny: a `.`-based lookahead can cross + * the boundary and fire on two commands neither of which triggers it alone. + * `" &&\n"` keeps every `&&`-aware segmenter alive while the newline stops + * `.`-based lookaheads crossing a boundary. + */ +export const BATCH_JOIN = " &&\n"; + +function omit(o: Record, key: string): Record { + const { [key]: _dropped, ...rest } = o; + return rest; +} + +export function expandBatchToolInput( + cli: IntegrationType, + toolName: string | undefined, + rawInput: unknown, +): BatchExpansion | null { + if (cli !== "cline" || !toolName) return null; + if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput)) return null; + const raw = rawInput as Record; + + // An expansion to ZERO elements would evaluate nothing and report a clean + // allow — the exact inert-hook failure this exists to prevent. So an empty or + // mis-shaped container returns null and falls back to the collapse safety net + // in canonicalizeClineToolInput, which at least still reads SOMETHING. + const mk = ( + parts: Array<{ input: Record; label: string }>, + ): BatchExpansion | null => + parts.length === 0 + ? null + : { tool: toolName, raw, elements: parts.map((e, index) => ({ ...e, index })) }; + + switch (toolName) { + // run_commands {commands: ["cd '' && ls -la", …]} + case "Bash": { + const cmds = raw.commands; + if (!Array.isArray(cmds)) return null; + return mk( + cmds + .filter((c): c is string => typeof c === "string") + .map((c) => ({ input: { ...omit(raw, "commands"), command: c }, label: c })), + ); + } + // read_files {files: [{path, start_line, …}, …]} + case "Read": { + const files = raw.files; + if (!Array.isArray(files)) return null; + return mk( + files.flatMap((f) => { + if (!f || typeof f !== "object" || Array.isArray(f)) return []; + const e = f as Record; + if (typeof e.path !== "string") return []; + // Spread the entry so start_line/limit survive for policies that read + // them; `path` is KEPT too, because a cline-aware custom policy may + // already be reading it. + return [{ input: { ...e, file_path: e.path }, label: e.path }]; + }), + ); + } + // search_codebase {queries: ["alpha", …]} + case "Grep": { + const qs = raw.queries; + if (!Array.isArray(qs)) return null; + return mk( + qs + .filter((q): q is string => typeof q === "string") + .map((q) => ({ input: { ...omit(raw, "queries"), pattern: q }, label: q })), + ); + } + // apply_patch {input: "*** Begin Patch …"} — byte-identical to the OpenAI + // apply_patch blob ori's `edit` tool carries, so this splitter is + // back-portable to ori and closes ori's documented multi-file KNOWN GAP. + case "Edit": { + const blob = raw.input; + if (typeof blob !== "string") return null; + return mk( + splitApplyPatch(blob).map((s) => ({ + input: { + file_path: s.path, + old_string: s.oldText, + new_string: s.newText, + patch: s.subPatch, + cline_patch_op: s.op, + }, + label: `${s.op} ${s.path}`, + })), + ); + } + default: + return null; + } +} + +export interface PatchSection { + op: "add" | "update" | "delete" | "move"; + path: string; + oldText: string; + newText: string; + /** A standalone, re-emittable patch for just this file. */ + subPatch: string; +} + +const FILE_OP_RE = /^\*\*\* (Add File|Update File|Delete File|Move to): (.+)$/; +const OPS: Record = { + "Add File": "add", + "Update File": "update", + "Delete File": "delete", + "Move to": "move", +}; +const HEADERS: Record = { + add: "Add File", + update: "Update File", + delete: "Delete File", + move: "Move to", +}; + +interface OpenSection { + op: PatchSection["op"]; + path: string; + body: string[]; +} + +function build(cur: OpenSection, op: PatchSection["op"], path: string): PatchSection { + const minus: string[] = []; + const plus: string[] = []; + for (const l of cur.body) { + if (l.startsWith("-")) minus.push(l.slice(1)); + else if (l.startsWith("+")) plus.push(l.slice(1)); + } + return { + op, + path, + oldText: minus.join("\n"), + newText: plus.join("\n"), + subPatch: ["*** Begin Patch", `*** ${HEADERS[op]}: ${path}`, ...cur.body, "*** End Patch"].join("\n"), + }; +} + +/** + * Split an OpenAI apply_patch blob into one section per file it touches. + * + * Serves BOTH cline's `apply_patch` and ori's `edit` — the format is identical, + * and one implementation is what stops a third CLI adding a third regex. + */ +export function splitApplyPatch(patch: string): PatchSection[] { + const out: PatchSection[] = []; + let cur: OpenSection | null = null; + + for (const line of patch.split("\n")) { + const m = FILE_OP_RE.exec(line); + if (m) { + const op = OPS[m[1]!]!; + const path = m[2]!.trim(); + if (op === "move") { + // `Move to:` is a RENAME MODIFIER on the preceding Update File, not an + // independent op. We emit it as its OWN element anyway — deliberately — + // so a rename INTO `.env` is seen by the path builtins. It inherits the + // in-progress body so old/new_string stay right, and the Update element + // is still emitted at the next flush. Cost: one rename yields two + // elements. That is the trade: a duplicate row, never a missed path. + // Flush the section being renamed FIRST, then emit the move, so paths + // come out in literal file order — `Update File: a` + `Move to: b` + // yields [a, b], never [b, a]. Getting this backwards silently changes + // which path `pickRiskiestPath` and `file_path = paths[0]` land on. + if (cur) { + out.push(build(cur, cur.op, cur.path)); + out.push(build(cur, "move", path)); + cur = null; + } else + out.push({ + op: "move", + path, + oldText: "", + newText: "", + subPatch: `*** Begin Patch\n*** Move to: ${path}\n*** End Patch`, + }); + continue; + } + if (cur) out.push(build(cur, cur.op, cur.path)); + cur = { op, path, body: [] }; + continue; + } + if (line.startsWith("*** ")) continue; // Begin Patch / End Patch / End of File + if (cur) cur.body.push(line); + } + if (cur) out.push(build(cur, cur.op, cur.path)); + return out; +} + +/** Every path an apply_patch blob touches, in file order. */ +export function applyPatchFilePaths(patch: string): string[] { + return splitApplyPatch(patch).map((s) => s.path); +} + +/** + * When a path list must still collapse to ONE (the overflow tail, and the + * non-fan-out safety net), pick the one a builtin is most likely to deny on + * rather than element [0]. The probes IMPORT the real builtin regexes — a + * second hand-maintained copy is how a bypass gets reintroduced by a builtin + * edit nobody thought to mirror. Falls back to paths[0] when nothing matches. + * + * NOT the primary mechanism, and it cannot be: it runs before params are bound, + * so `block-secrets-write`'s `additionalPatterns` and `block-read-outside-cwd`'s + * `allowPaths` have no influence on it, and neither does any custom policy. + * That is exactly why PreToolUse fans out instead of relying on this. + */ +export function pickRiskiestPath(paths: string[]): string | undefined { + if (paths.length === 0) return undefined; + for (const re of [ + ENV_FILE_PATH_RE, + SECRET_FILE_RE, + SECRET_FILE_ID_RSA_RE, + SECRET_FILE_CREDENTIALS_RE, + ]) { + const hit = paths.find((p) => re.test(p)); + if (hit) return hit; + } + return paths[0]; +} + +/** Everything past the cap / budget, joined into ONE degraded element. It is + * still EVALUATED — a weaker guarantee we can state honestly, never a silent + * skip. */ +export function collapseElements(tool: string, els: FanoutElement[]): FanoutElement { + const first = els[0]!; + const base = { label: `${els.length} collapsed`, index: first.index, degraded: true as const }; + switch (tool) { + case "Bash": + return { + ...base, + input: { + ...first.input, + command: els.map((e) => String(e.input.command ?? "")).join(BATCH_JOIN), + }, + }; + case "Read": + case "Edit": { + const paths = els.map((e) => String(e.input.file_path ?? "")).filter(Boolean); + return { + ...base, + input: { ...first.input, file_path: pickRiskiestPath(paths), cline_collapsed_paths: paths }, + }; + } + case "Grep": + return { + ...base, + input: { ...first.input, pattern: els.map((e) => String(e.input.pattern ?? "")).join("|") }, + }; + default: + return { ...first, ...base }; + } +} + +/** + * Cline → canonical, for every path that does NOT fan out: PostToolUse, audit + * replay, fail-closed shaping, and a policy reading `ctx.payload`. Derives the + * best single scalar and PRESERVES the arrays under `cline_*` so a batch-aware + * policy can still see everything. + * + * This is a SAFETY NET, not the enforcement mechanism: a collapse cannot make + * an anchored regex match every element, which is precisely why PreToolUse fans + * out instead. `pickRiskiestPath` is what makes the net worth having — element + * [0] would silently miss a `.env` at `files[1]`. + */ +export function canonicalizeClineToolInput( + toolName: string, + input: Record, +): Record { + switch (toolName) { + case "Bash": { + const cs = input.commands; + if (!Array.isArray(cs)) return input; + const commands = cs.filter((c): c is string => typeof c === "string"); + if (commands.length === 0) return input; + return { ...omit(input, "commands"), command: commands.join(BATCH_JOIN), cline_commands: commands }; + } + case "Read": { + const files = input.files; + if (!Array.isArray(files)) return input; + const paths = files + .map((f) => + f && typeof f === "object" && !Array.isArray(f) + ? (f as Record).path + : undefined, + ) + .filter((p): p is string => typeof p === "string"); + if (paths.length === 0) return input; + return { + ...omit(input, "files"), + file_path: pickRiskiestPath(paths), + cline_files: files, + cline_file_paths: paths, + }; + } + case "Grep": { + const qs = input.queries; + if (!Array.isArray(qs)) return input; + const queries = qs.filter((q): q is string => typeof q === "string"); + if (queries.length === 0) return input; + return { ...omit(input, "queries"), pattern: queries.join("|"), cline_queries: queries }; + } + case "Edit": { + const blob = input.input; + if (typeof blob !== "string") return input; + const sections = splitApplyPatch(blob); + if (sections.length === 0) return input; + const paths = sections.map((s) => s.path); + return { + ...input, + patch: blob, + file_path: pickRiskiestPath(paths), + old_string: sections.map((s) => s.oldText).join("\n"), + new_string: sections.map((s) => s.newText).join("\n"), + cline_patch_files: paths, + }; + } + default: + return input; + } +} diff --git a/src/hooks/batch-fanout.ts b/src/hooks/batch-fanout.ts new file mode 100644 index 000000000..91ca3ab3b --- /dev/null +++ b/src/hooks/batch-fanout.ts @@ -0,0 +1,142 @@ +/** + * Run the policy set once per element of an expanded batch tool call. + * + * Why this exists rather than a cleverer canonicalization: cline's tools carry + * ARRAYS, and collapsing an array into one scalar cannot preserve an ANCHORED + * regex. `SECRET_FILE_RE` is `/\.(?:pem|key)$/`, so under any join only the last + * path can match and a `.pem` at `files[0]` rides straight through — a silent + * allow, which is the failure mode this repo has already shipped twice. + * + * Combination rules: + * all allow, no notes → plain allow, identical to a non-batch call + * any deny → LOWEST element index wins, returned verbatim, + * short-circuiting the rest + * no deny, >=1 instruct → union deduped by (policy, reason), shaped ONCE + * no deny/instruct, >=1 note→ same + * + * The deny is returned verbatim because `evaluatePolicies` already shaped it for + * the calling CLI and already embedded the element locator (it received + * `opts.batch`). No re-shaping, and therefore nothing to keep in sync with the + * 16 per-CLI deny shapes. + */ +import { evaluatePolicies, type EvaluationResult } from "./policy-evaluator"; +import { collapseElements, type BatchExpansion, type FanoutElement } from "./batch-expand"; +import { hookLogWarn } from "./hook-logger"; +import type { HookEventType, SessionMetadata } from "./types"; +import type { HooksConfig } from "./policy-types"; + +/** A shape guard, not the real one: 32 elements of registered policies sits + * comfortably inside daemon-client.ts's 30s RESPONSE budget. The wall clock + * below is what actually protects that budget. */ +const MAX_ELEMENTS = 32; +const WALL_CLOCK_BUDGET_MS = 5_000; + +export interface BatchOutcome { + size: number; + evaluated: number; + decidedIndex: number | null; + decidedLabel: string | null; + degraded: boolean; +} + +type Entry = { policyName: string; reason: string }; + +/** Policy P saying the SAME thing about elements 3 and 7 contributes one line; + * P saying two different things contributes both. Keyed on (name, reason). */ +function pushDeduped(into: Entry[], from: Entry[] | undefined): void { + for (const e of from ?? []) { + const k = `${e.policyName}\t${e.reason}`; + if (!into.some((x) => `${x.policyName}\t${x.reason}` === k)) into.push(e); + } +} + +export async function evaluateExpandedBatch( + eventType: HookEventType, + payload: Record, + session: SessionMetadata | undefined, + config: HooksConfig | undefined, + expansion: BatchExpansion, + dedupe: Set, +): Promise { + const started = Date.now(); + + let queue: FanoutElement[] = + expansion.elements.length > MAX_ELEMENTS + ? [ + ...expansion.elements.slice(0, MAX_ELEMENTS), + collapseElements(expansion.tool, expansion.elements.slice(MAX_ELEMENTS)), + ] + : [...expansion.elements]; + + const instructs: Entry[] = []; + const allows: Entry[] = []; + let evaluated = 0; + let sawDegraded = false; + + for (let i = 0; i < queue.length; i++) { + // Checked BETWEEN elements only — a policy already inside its own timeout + // race is not interruptible from here. + if (i > 0 && Date.now() - started > WALL_CLOCK_BUDGET_MS) { + const rest = queue.slice(i); + hookLogWarn( + `batch fan-out budget exceeded after ${i}/${queue.length} elements; the remaining ` + + `${rest.length} are being evaluated COLLAPSED — anchored patterns may not match`, + ); + queue = [...queue.slice(0, i), collapseElements(expansion.tool, rest)]; + } + const el = queue[i]!; + sawDegraded = sawDegraded || !!el.degraded; + + const r = await evaluatePolicies( + eventType, + { ...payload, tool_input: el.input }, + session, + config, + { + dedupe, + collectEntries: true, + batch: { + index: el.index, + count: expansion.elements.length, + label: el.label, + degraded: !!el.degraded, + all: expansion.elements.map((e) => e.input), + raw: expansion.raw, + }, + }, + ); + evaluated++; + + if (r.decision === "deny") { + return { + ...r, + batch: { + size: expansion.elements.length, + evaluated, + decidedIndex: el.index, + decidedLabel: el.label, + degraded: sawDegraded, + }, + }; + } + pushDeduped(instructs, r.instructEntries); + pushDeduped(allows, r.allowEntries); + } + + // One shaping pass over the deduped union, through the SAME per-CLI tails + // every other integration uses — no shaping code was extracted to get here. + const shaped = await evaluatePolicies(eventType, payload, session, config, { + dedupe, + preEvaluated: { instructEntries: instructs, allowEntries: allows }, + }); + return { + ...shaped, + batch: { + size: expansion.elements.length, + evaluated, + decidedIndex: null, + decidedLabel: null, + degraded: sawDegraded, + }, + }; +} diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 8ca252205..8b1640b01 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -16,6 +16,12 @@ import { POLICY_CATALOG } from "./policy-catalog"; import { allow, deny, instruct } from "./policy-helpers"; import { normalizePolicyName, registerPolicy } from "./policy-registry"; import { hookLogWarn } from "./hook-logger"; +import { + ENV_FILE_PATH_RE, + SECRET_FILE_RE, + SECRET_FILE_ID_RSA_RE, + SECRET_FILE_CREDENTIALS_RE, +} from "./risk-patterns"; /** * Whether `resolved` lives under an agent CLI's home directory @@ -192,7 +198,6 @@ const DOTNET_GETENV_RE = /\[Environment\]::GetEnvironment/i; const CMD_ECHO_ENV_RE = /echo\s+%[A-Za-z_]/i; // blockEnvFiles -const ENV_FILE_PATH_RE = /(?:^|[\\/])\.env(?:\.|$)/; const ENV_CMD_RE = /\.env(?:\b|\s|$|\.)/; // blockSudo @@ -444,9 +449,6 @@ const SHORT_FLAG_BUNDLE_RE = /^-[a-zA-Z]*f[a-zA-Z]*$/; const SAFE_FORCE_PREFIXES = ["--force-with-lease", "--force-if-includes"] as const; // blockSecretsWrite -const SECRET_FILE_RE = /\.(?:pem|key)$/; -const SECRET_FILE_ID_RSA_RE = /id_rsa/; -const SECRET_FILE_CREDENTIALS_RE = /credentials/; // blockWorkOnMain const GIT_COMMIT_MERGE_RE = /git\s+(commit|merge|rebase|cherry-pick)\b/; diff --git a/src/hooks/enforcement-capability.ts b/src/hooks/enforcement-capability.ts index e73cc45a1..d7fb53cc3 100644 --- a/src/hooks/enforcement-capability.ts +++ b/src/hooks/enforcement-capability.ts @@ -253,6 +253,103 @@ export const ENFORCEMENT_CAPABILITY: Record< // 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). }, + + // grok 1.0.3 (1a29d5bc12). Every "block" row below was proven by a live + // probe, not read from grok's hooks doc — which is also where two of the + // three surprises came from (see types.ts): the doc's own tool name for the + // shell tool disagrees with the wire, and nothing documents that project + // hooks need a git repo. grok's capability advertisement corroborates the + // blocking set: its ACP initialize response carries + // x.ai/hooks: {blockingEvents:["pre_tool_use","stop","subagent_stop"], + // decisions:["deny","block"], stopSignals:[…]} + // — exactly the three rows marked "block" here. + grok: { + PreToolUse: "block", // VERIFIED live: {decision:"deny",reason} blocked `echo` and beat --yolo (permissionMode bypassPermissions); Claude's hookSpecificOutput shape did NOT block (A/B on the same hook) + Stop: "block", // VERIFIED live: {decision:"block",reason} forced another turn — the agent ran the required command, then stopped. ONLY on reason==="end_turn"; the session-shutdown fire is parsed and discarded upstream. Cap: 8 continuations/turn + SubagentStop: "block", // advertised in x.ai/hooks blockingEvents; not exercised by a probe + UserPromptSubmit: "observe", // fired live; not in blockingEvents + PostToolUse: "observe", // fired live; post-hoc, and not in blockingEvents + PostToolUseFailure: "observe", + SessionStart: "observe", // fired live + SessionEnd: "observe", // fired live + // The six below are the widened set. All OBSERVE by construction, not by + // caution: blockingEvents lists exactly three events, so grok cannot honour + // a deny on any of these no matter what we send. + StopFailure: "observe", // VERIFIED firing (a 429 turn): {error:"rate_limit", errorDetails, lastAssistantMessage} + Notification: "observe", // VERIFIED firing: notificationType "agent_error" — a type grok's own docs do not list + PermissionDenied: "observe", // config key accepted (count=14); firing not exercised — needs a real permission denial + SubagentStart: "observe", // config key accepted; firing not exercised — needs a subagent + PreCompact: "observe", // config key accepted; firing not exercised — needs compaction + PostCompact: "observe", // config key accepted; firing not exercised — needs compaction + }, + + // qwen-code 0.21.12. A near-pure Claude clone on the wire, so most rows + // inherit Claude's semantics — but only the two proven ones are "block". + qwen: { + PreToolUse: "block", // VERIFIED live: hookSpecificOutput.permissionDecision:"deny" blocked run_shell_command and beat -y (yolo); reason reached the model verbatim. ("ask" degrades to deny in headless and background subagents) + Stop: "block", // VERIFIED live: top-level {decision:"block",reason} forced another turn. NOTE stop_hook_active is true on the FIRST fire, so it is not a usable loop guard here + UserPromptSubmit: "observe", // docs accept a decision, unverified — and it fires per MODEL INVOCATION (4× in one observed turn), so treat as observation until probed + PostToolUse: "observe", // docs accept `decision`, unverified; post-hoc regardless + PostToolUseFailure: "observe", + PermissionRequest: "observe", + PermissionDenied: "observe", + SubagentStart: "observe", + SubagentStop: "observe", // sibling of Stop upstream, but not probed — left honest rather than assumed + PreCompact: "observe", + SessionStart: "observe", // fired live + SessionEnd: "observe", + // Widened set. Every one has a real executeHooks() dispatch site in the + // shipped bundle; the three marked VERIFIED were also observed firing. + TaskCreated: "block", // qwen's TodoCreated. VERIFIED firing ×4 with phase:"validation", where upstream docs state a {decision:"block"} PREVENTS the write and returns the reason to the model + TaskCompleted: "block", // qwen's TodoCompleted. VERIFIED firing ×4, same validation-phase contract + InstructionsLoaded: "observe", // VERIFIED firing: {file_path, memory_type, load_reason} + Notification: "observe", // VERIFIED firing: notification_type "auth_success" + StopFailure: "observe", // upstream documents it fire-and-forget — output and exit code ignored + PostCompact: "observe", + UserPromptExpansion: "observe", + }, + + // ── ori ─────────────────────────────────────────────────────────────────── + // ori 0.12.0+68f9a36, probed LIVE against the built-in `@ori-runloop/ + // agent-loop` harness driving nvidia/nemotron-3.5-lightning:free. Enforcement + // is the `approval-asker` / `unattended-approvals` extension points rather + // than a hook-event stream, so PreToolUse is the only row there can be: ori + // exposes no prompt-submit, post-tool, session or stop gate to subscribe to. + ori: { + PreToolUse: "block", // LIVE: {outcome:"deny"} from our approval-asker provision turned 3 tool.started into 3 tool.failed with ZERO tool.succeeded, and ori held the deny across the model's retries. MODE-GATED — read the note below before quoting this row + // DELIBERATELY NO OTHER ROWS. Two things this table cannot express, both + // verified live and both load-bearing: + // 1. ori's approval mode defaults to `self-drive`, and in that mode the + // DYNAMIC points are never called — so no deny is ever PRODUCED, and + // the row above never gets a chance to apply. Isolated three ways: no + // callback under self-drive; still none after claiming approval-policy + // with defaultAction:"ask"; but defaultAction:"reject" DID block every + // call, which proves the static point is wired and that self-drive + // skips specifically the dynamic asker. Coverage therefore requires + // `--approvals manual` (or /approvals in the TUI). + // 2. There is no Stop event at all, so the 5 require-*-before-stop + // builtins are INAPPLICABLE on ori, exactly as on Hermes and Goose. + }, + + // ── cline ───────────────────────────────────────────────────────────────── + // cline v3.0.60. Its hooks are event-NAMED scripts under a hooks directory, + // and THE EXIT CODE IS IGNORED on every event — so every row here is a claim + // about a stdout SHAPE, never about exit 2. cline's verdict schema has no + // `decision` / `block` / `permissionDecision` field at all, which is why every + // generic branch in policy-evaluator.ts is inert for it. + cline: { + PreToolUse: "block", // LIVE: {"cancel":true,"errorMessage":…} on stdout stopped a real run_commands call and the side effect never happened. CAVEAT, and it is a product decision rather than a footnote: `cancel` becomes {stop:true} -> applyStopControl THROWS ControlledStopError, ABORTING THE WHOLE RUN ("[abort] aborted by another client"), not just this tool call. A STRONGER action than a per-tool deny, not a weaker one + Stop: "observe", // TaskComplete maps here, and `cancel` is NOT a force-retry channel: the task has already completed, so a cancel kills a finished run rather than re-entering the loop. The 5 require-*-before-stop builtins are INAPPLICABLE on cline, exactly as on Hermes, Goose and ori + // DELIBERATELY NO OTHER ROWS. PostToolUse, UserPromptSubmit, TaskStart, + // TaskResume, TaskCancel, TaskError and SessionShutdown ARE installed — for + // the audit trail and for observe-mode custom policies — but nothing has + // been probed showing a verdict on any of them changes what the agent does, + // and ABSENT MEANS UNKNOWN in this file. + // + // FAIL-OPEN across the whole surface, and unlike ori there is no + // failureBehavior:"deny" to inherit: a timeout (120s default), a parse + // failure or a spawn error makes cline SKIP the hook and run the tool. + }, }; /** diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 9503226d4..36c8d9ac4 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -15,6 +15,7 @@ import type { HermesHookEventType, OpenClawHookEventType, AntigravityHookEventType, + QwenHookEventType, } from "./types"; import { CODEX_EVENT_MAP, @@ -23,9 +24,12 @@ import { HERMES_EVENT_MAP, OPENCLAW_EVENT_MAP, ANTIGRAVITY_EVENT_MAP, + QWEN_EVENT_MAP, } from "./types"; import { canonicalizeToolName, canonicalizeToolInput } from "./tool-name-canonicalize"; -import { normalizeCliPayload } from "./normalize-cli-payload"; +import { expandBatchToolInput } from "./batch-expand"; +import { evaluateExpandedBatch } from "./batch-fanout"; +import { normalizeCliPayload, resolveEffectiveCli } from "./normalize-cli-payload"; import type { PolicyFunction, PolicyResult, HooksConfig } from "./policy-types"; import { readMergedHooksConfig } from "./hooks-config"; import { registerBuiltinPolicies } from "./builtin-policies"; @@ -93,6 +97,13 @@ export function canonicalizeEventType(raw: string, cli: IntegrationType): HookEv const mapped = ANTIGRAVITY_EVENT_MAP[raw as AntigravityHookEventType]; if (mapped) return mapped; } + if (cli === "qwen") { + // Seventeen of qwen's nineteen events are already canonical; this maps the + // two that are not — TodoCreated/TodoCompleted, which are qwen's spelling + // of TaskCreated/TaskCompleted. Verified live against qwen-code 0.21.12. + const mapped = QWEN_EVENT_MAP[raw as QwenHookEventType]; + if (mapped) return mapped; + } // claude / copilot / unknown — already PascalCase, pass through. // HOOK_EVENT_TYPES type-checks downstream. return raw as HookEventType; @@ -181,7 +192,7 @@ async function runObserved( */ export async function evaluateHookEvent( eventType: string, - cli: IntegrationType = "claude", + declaredCli: IntegrationType = "claude", stdinPayload: string, opts?: EvaluateHookEventOptions, ): Promise { @@ -202,12 +213,28 @@ export async function evaluateHookEvent( hookLogWarn(`payload parse failed for ${eventType} (${stdinPayload.length} bytes)`); void trackHookEvent(getInstanceId(), "hook_payload_parse_error", { event_type: eventType, - cli, + cli: declaredCli, payload_size: stdinPayload.length, }); } } + // grok executes other CLIs' hook configs — including the + // `/.claude/settings.json` our own claude install writes — passing + // `--cli claude` while piping ITS camelCase payload. Resolve the CLI whose + // contract actually governs this event BEFORE anything reads `cli`, so both + // halves land on grok's path: the tool maps (without which every builtin + // reads undefined) and the response shape (grok ignores Claude's + // hookSpecificOutput deny — verified by A/B against a live session). + // No-op for every other CLI. See normalize-cli-payload.ts:isGrokEnvelope. + const cli = resolveEffectiveCli(declaredCli, parsed); + if (cli !== declaredCli) { + hookLogWarn( + `payload for ${eventType} is a ${cli} envelope but --cli says ${declaredCli}; ` + + `evaluating with the ${cli} contract`, + ); + } + normalizeCliPayload(cli, parsed); // Canonicalize event name (Codex sends snake_case; internals expect PascalCase) @@ -229,6 +256,11 @@ export async function evaluateHookEvent( // passes are idempotent because the camelCase keys won't match a // snake_case input. const rawInput = parsed.tool_input; + // Read the RAW batch BEFORE canonicalization collapses it. The expander + // emits canonical scalars directly, so there is no ambiguity about whether + // canonicalization ran once, twice, or on the wrong shape. Returns null for + // every CLI except cline, so the other 15 take the unchanged path below. + const batchExpansion = expandBatchToolInput(cli, canonicalToolName, rawInput); const canonicalInput = canonicalizeToolInput(canonicalToolName, rawInput, cli); if (canonicalInput !== rawInput) { parsed.tool_input = canonicalInput; @@ -645,8 +677,32 @@ export async function evaluateHookEvent( ); } - // Evaluate policies (use canonical PascalCase event type) - const result = await evaluatePolicies(canonicalEventType, parsed, session, config); + // Telemetry keys already emitted in THIS invocation. Threaded into the + // evaluator so a batch fan-out reports one event per real fault rather than + // one per element. A null set (every non-batch call) changes nothing. + const telemetryDedupe = new Set(); + + // Evaluate policies (use canonical PascalCase event type). + // + // Fan out only on PreToolUse: it is the only cline payload shape captured + // live, and the only event where a per-element verdict changes anything. + // forceDecision is excluded — its synthetic policy denies unconditionally, + // so N elements would produce N identical denies and a meaningless locator. + const result = + batchExpansion && canonicalEventType === "PreToolUse" && !opts?.forceDecision + ? await evaluateExpandedBatch( + canonicalEventType, + parsed, + session, + config, + batchExpansion, + telemetryDedupe, + ) + // Deliberately NOT passed the dedupe set: it exists only to collapse + // per-element telemetry inside a fan-out, and a single-shot call has + // exactly one element. Passing it would change this call's shape for + // all 16 CLIs to no effect. + : await evaluatePolicies(canonicalEventType, parsed, session, config); const durationMs = Math.round(performance.now() - startTime); hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`); diff --git a/src/hooks/harness-cli.ts b/src/hooks/harness-cli.ts index a6b554983..456d2da81 100644 --- a/src/hooks/harness-cli.ts +++ b/src/hooks/harness-cli.ts @@ -60,7 +60,7 @@ import { * the two lists are identical by reading the Rust source, because nothing else * connects them. * - * Twelve keys, thirteen sources: `claude` covers both the main and the subagent + * Fourteen keys, fifteen sources: `claude` covers both the main and the subagent * transcript formats, which share a root — an extra path holding Claude * transcripts holds their subagents too. */ @@ -77,6 +77,8 @@ export const HARNESS_KEYS = [ "opencode", "devin", "hermes", + "grok", + "qwen", ] as const; export type HarnessKey = (typeof HARNESS_KEYS)[number]; diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index f809f61f8..0cd2b2d95 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -7,7 +7,7 @@ * is agent-agnostic — only install/uninstall plumbing varies. */ import { execSync } from "node:child_process"; -import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, rmSync, chmodSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; @@ -38,6 +38,14 @@ import { ANTIGRAVITY_HOOK_EVENT_TYPES, ANTIGRAVITY_HOOK_SCOPES, GOOSE_HOOK_EVENT_TYPES, + GROK_HOOK_EVENT_TYPES, + GROK_HOOK_SCOPES, + QWEN_HOOK_EVENT_TYPES, + QWEN_HOOK_SCOPES, + ORI_HOOK_SCOPES, + ORI_HOOK_EVENT_TYPES, + CLINE_HOOK_SCOPES, + CLINE_HOOK_EVENT_TYPES, GOOSE_HOOK_SCOPES, FAILPROOFAI_HOOK_MARKER, INTEGRATION_TYPES, @@ -2275,13 +2283,755 @@ export const goose: Integration = { }, }; -// ── Registry ──────────────────────────────────────────────────────────────── + +// ── Ori (OpenRouter's ori) ────────────────────────────────────────────────── +// +// Enforces on ori's OWN agent (bare `ori` / `ori code`), NOT on its launcher +// path — `ori claude`, `ori codex`, `ori grok`, `ori opencode` and friends run +// the real third-party binary with credentials injected and no config-dir +// redirect, so the hooks already installed for those CLIs keep enforcing there +// with nothing ori-specific required. See the ORI section of types.ts for the +// live evidence behind that. +// +// A feature is a workspace package under the global workspace's `features/`, +// AUTO-DISCOVERED with no config file to register it in — so, like Goose, the +// install is "drop a directory" rather than "edit a settings file". The +// bookkeeping JSON below exists purely so the Integration interface has a +// settings path to read, write and detect against; ori itself ignores it. +// +// USER scope only: bare `ori` boots the GLOBAL workspace rather than the +// project's, so one install covers every project and a project-scope install +// would never be loaded. +export const ORI_FEATURE_ID = "failproofai"; + +/** ori's home (override with ORI_HOME, used by tests). */ +export function oriHome(): string { + return process.env.ORI_HOME || resolve(homedir(), ".ori"); +} + +/** The feature directory failproofai generates inside ori's global workspace. */ +export function oriFeatureDir(): string { + return resolve(oriHome(), "global", "features", ORI_FEATURE_ID); +} + +/** + * The ori feature shim. + * + * Claims all three published approval extension points. Each is `unique`, so + * leaving one unclaimed invites another feature to take it — but the STATIC one + * is deliberately inert (`defaultAction: "ask"`, which is what self-drive + * already does). Claiming it with a blanket `reject` would brick every + * self-drive session the moment failproofai is installed. + * + * FAIL-CLOSED BY DEFAULT, and this is the one integration where that is free: + * ori declares `failureBehavior: "deny"` on both dynamic points — "a throwing, + * rejecting, or malformed provider denies the request" — so letting an error + * propagate IS the deny. Set FAILPROOFAI_ORI_FAIL_OPEN=1 to allow instead, for + * anyone who would rather a failproofai fault not stop their agent. + */ +function buildOriFeatureShim(binaryPath: string): string { + return `// AUTO-GENERATED by failproofai. ${FAILPROOFAI_HOOK_MARKER} +// Re-generate via: failproofai policies --install --cli ori +// +// Bridges ori's approval extension points to the failproofai binary. Ships NO +// inline tool maps: it forwards ori's raw tool name and arguments, and the +// binary canonicalizes them via ORI_TOOL_MAP / ORI_TOOL_INPUT_MAP (one source +// of truth), exactly as the OpenClaw plugin does. +import { spawn } from "node:child_process"; + +import { ApprovalAskers, ApprovalPolicies, UnattendedApprovals } from "ori"; + +const BIN = ${JSON.stringify(binaryPath)}; +const FAIL_OPEN = process.env.FAILPROOFAI_ORI_FAIL_OPEN === "1"; + +/** ori hands arguments as a flat {name, value} string array. Rebuild the object + * the binary's tool-input canonicalization expects. The synthetic + * \`escalated\` entry ori appends on the second pass of its escalation ladder + * is metadata, not a tool argument, so it is dropped from the tool input. */ +function toToolInput(request) { + const out = {}; + for (const a of request.arguments ?? []) { + if (request.escalated && a.name === "escalated" && a.value === "true") continue; + out[a.name] = a.value; + } + return out; +} + +function callPolicy(request) { + return new Promise((done, fail) => { + let child; + try { + child = spawn("node", [BIN, "--hook", "PreToolUse", "--cli", "ori"], { + stdio: ["pipe", "pipe", "inherit"], + }); + } catch (err) { + FAIL_OPEN ? done({ permission: "allow" }) : fail(err); + return; + } + let out = ""; + let settled = false; + const finish = (fn, arg) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(arg); + }; + const timer = setTimeout(() => { + try { child.kill(); } catch { /* already gone */ } + FAIL_OPEN + ? finish(done, { permission: "allow" }) + : finish(fail, new Error("failproofai timed out")); + }, 30_000); + child.stdout.on("data", (d) => { out += d; }); + child.on("error", (err) => { + FAIL_OPEN ? finish(done, { permission: "allow" }) : finish(fail, err); + }); + child.on("close", (code, signal) => { + // A non-zero exit or a signal means failproofai produced NO verdict — a + // missing bundle, a crashed evaluator, an OOM kill. Resolving that to + // "allow" is exactly the silent non-enforcement ori's + // failureBehavior deny exists to prevent, and it could never fire, + // because resolving is what tells ori the provider succeeded. Only a + // REJECT reaches that guarantee. + if (signal || (typeof code === "number" && code !== 0)) { + const err = new Error( + "failproofai produced no verdict (" + + (signal ? "killed by " + signal : "exit code " + code) + + ")", + ); + FAIL_OPEN ? finish(done, { permission: "allow" }) : finish(fail, err); + return; + } + const stdout = out.trim(); + // Exit 0 with empty stdout IS the evaluator's clean-allow shape — the one + // case where no output is a real verdict rather than a failure. + if (!stdout) { finish(done, { permission: "allow" }); return; } + let verdict; + try { + verdict = JSON.parse(stdout); + } catch (err) { + FAIL_OPEN ? finish(done, { permission: "allow" }) : finish(fail, err); + return; + } + // Anything that is not an explicit allow/deny is an unrecognised answer, + // not an allow. decide() maps every non-"deny" to allow, so an + // unvalidated object would silently become one. + if (!verdict || (verdict.permission !== "allow" && verdict.permission !== "deny")) { + const err = new Error("failproofai returned an unrecognised verdict: " + stdout.slice(0, 200)); + FAIL_OPEN ? finish(done, { permission: "allow" }) : finish(fail, err); + return; + } + finish(done, verdict); + }); + try { + child.stdin.end(JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: request.tool, + tool_input: toToolInput(request), + cwd: process.cwd(), + ori: { + capabilities: request.capabilities ?? [], + escalated: request.escalated === true, + }, + })); + } catch (err) { + FAIL_OPEN ? finish(done, { permission: "allow" }) : finish(fail, err); + } + }); +} + +async function decide(request) { + const verdict = await callPolicy(request); + return { outcome: verdict.permission === "deny" ? "deny" : "allow" }; +} + +export const provisions = [ + // Claimed so no other feature can take it; inert on purpose — see above. + ApprovalPolicies.provide({ defaultAction: "ask", rules: [] }), + // The gate that fires when a human is present (\`ori --approvals manual\`). + ApprovalAskers.provide(decide), + // The gate that fires on unattended runs. + UnattendedApprovals.provide(decide), +]; +`; +} + +const ORI_FEATURE_PACKAGE_JSON = `${JSON.stringify( + { name: "failproofai", private: true, type: "module" }, + null, + 2, +)}\n`; + +export const ori: Integration = { + id: "ori", + displayName: "Ori", + scopes: ORI_HOOK_SCOPES, + eventTypes: ORI_HOOK_EVENT_TYPES, + + // USER scope only — ori has no project workspace that bare `ori` would boot. + // The path is bookkeeping for this interface, not something ori reads. + getSettingsPath() { + return resolve(oriFeatureDir(), "failproofai.json"); + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType) { + return { type: "command", command: `"${binaryPath}" --hook ${eventType} --cli ori`, [FAILPROOFAI_HOOK_MARKER]: true }; + }, + + isFailproofaiHook(hook) { + if (!hook || typeof hook !== "object") return false; + return (hook as Record)[FAILPROOFAI_HOOK_MARKER] === true; + }, + + /** + * Install: write the feature (feature.ts + package.json) into ori's global + * workspace, and record the entry in the bookkeeping JSON. ori discovers the + * directory on its own — there is no config array to append to. + */ + writeHookEntries(settings, binaryPath) { + const dir = oriFeatureDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(resolve(dir, "feature.ts"), buildOriFeatureShim(binaryPath), "utf8"); + writeFileSync(resolve(dir, "package.json"), ORI_FEATURE_PACKAGE_JSON, "utf8"); + + const s = settings as Record; + s.hooks = ORI_HOOK_EVENT_TYPES.map((eventType) => + this.buildHookEntry(binaryPath, eventType, "user"), + ); + }, + + /** + * Uninstall: drop the whole feature directory, but ONLY when the generated + * feature carries our marker — never delete a hand-written ori feature that + * happens to sit at the same path. + */ + removeHooksFromFile(settingsPath) { + const dir = dirname(settingsPath); + const featurePath = resolve(dir, "feature.ts"); + let removed = 0; + + const settings = this.readSettings(settingsPath) as Record; + if (Array.isArray(settings.hooks)) { + removed = settings.hooks.filter((h) => this.isFailproofaiHook(h)).length; + } + + const ours = + existsSync(featurePath) && + readFileSync(featurePath, "utf8").includes(FAILPROOFAI_HOOK_MARKER); + if (ours) { + rmSync(dir, { recursive: true, force: true }); + return removed || 1; + } + + // Someone replaced the feature by hand — leave it, drop only our record. + delete settings.hooks; + this.writeSettings(settingsPath, settings); + return removed; + }, + + hooksInstalledInSettings() { + const featurePath = resolve(oriFeatureDir(), "feature.ts"); + if (!existsSync(featurePath)) return false; + try { + return readFileSync(featurePath, "utf8").includes(FAILPROOFAI_HOOK_MARKER); + } catch { + return false; + } + }, + + detectInstalled() { + return binaryExists("ori"); + }, +}; + + +// ── Cline (cline CLI) ─────────────────────────────────────────────────────── +// +// Cline's config is a DIRECTORY OF EVENT-NAMED FILES, not a settings file — the +// only integration shaped that way. There is no JSON to merge and no array to +// append to: the install drops one launcher script per event, named exactly for +// the event it subscribes to. +// +// Two rules from the live contract govern the whole install (cline v3.0.60): +// 1. FILENAME == EVENT, case-insensitively, and only ten names exist. A typo +// is not an error, it is SILENCE — which is exactly how an earlier probe +// concluded, wrongly, that cline had no reachable hook surface. +// 2. The EXTENSION must be in cline's allowlist or the file is skipped, also +// silently. We write `.sh` with a matching shebang so the interpreter is +// unambiguous whichever rule cline applies (shebang first, else extension). +// +// THE CRITICAL DIFFERENCE FROM ORI: ori owns its entire feature directory, so +// uninstall can remove it wholesale. Cline's hooks directory is SHARED with the +// user's own hooks, so uninstall deletes individual files carrying our marker +// and MUST NEVER remove the directory — deleting someone's hand-written +// `PreToolUse.py` because it sat at the same path is unrecoverable. +// +// NEVER emit `--hooks-dir`: that flag writes CLINE_HOOKS_DIR, which appears +// exactly once in cline's shipped binary — the write — and is read by nothing. + +/** The extension we write. In cline's allowlist, and paired with a shebang. */ +export const CLINE_HOOK_FILE_EXTENSION = ".sh"; + +/** cline's home (CLINE_DIR is cline's own var; CLINE_HOME is for tests). */ +export function clineIntegrationHome(): string { + return process.env.CLINE_HOME || process.env.CLINE_DIR || resolve(homedir(), ".cline"); +} + +/** + * cline scans four directories; we write the two that map onto our scopes. + * User scope is `$CLINE_DIR/hooks`; project scope is `/.clinerules/hooks`. + */ +export function clineHooksDir(scope: HookScope, cwd?: string): string { + const base = cwd ? resolve(cwd) : process.cwd(); + return scope === "user" + ? resolve(clineIntegrationHome(), "hooks") + : resolve(base, ".clinerules", "hooks"); +} + +function buildClineHookScript(binaryPath: string, eventType: string, scope: HookScope): string { + const invocation = scope === "project" ? "npx -y failproofai" : `"${binaryPath}"`; + return `#!/usr/bin/env bash +# AUTO-GENERATED by failproofai. ${FAILPROOFAI_HOOK_MARKER} +# Re-generate via: failproofai policies --install --cli cline +# +# THE FILENAME IS THE EVENT. cline binds this file to its ${eventType} event by +# basename (case-insensitively) and by nothing else: renaming it silently +# unsubscribes it, and an extension outside cline's allowlist is skipped without +# a log line. Verified against cline v3.0.60. +# +# cline IGNORES THE EXIT CODE. The verdict is the single JSON object on stdout, +# so this script must ALWAYS print exactly one object, and must print '{}' when +# failproofai produced nothing: unparseable stdout makes cline skip the hook and +# run the tool anyway (fail-open, 120s default timeout). +# +# Blocking shape, the only one cline reads: {"cancel":true,"errorMessage":"..."}. +# There is no decision/block/permissionDecision field in cline's schema. +set -u + +payload="$(cat)" + +# stderr is deliberately NOT redirected: failproofai's diagnostics belong in +# cline's own hook log, and stdout stays clean because the evaluator writes +# every operator note to stderr. +verdict="$(printf '%s' "$payload" | ${invocation} --hook ${eventType} --cli cline)" + +if [ -z "$verdict" ]; then + verdict='{}' +fi +printf '%s\\n' "$verdict" +exit 0 +`; +} + +export const cline: Integration = { + id: "cline", + displayName: "Cline", + scopes: CLINE_HOOK_SCOPES, + eventTypes: CLINE_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + // Bookkeeping only, so the Integration interface has something to read, + // write and detect against. Inert for cline twice over: `.json` is not in + // the extension allowlist AND `failproofai` is not one of the ten event + // names, so cline never executes it. + return resolve( + clineHooksDir(scope === "local" ? "project" : scope, cwd), + "failproofai.json", + ); + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const effective: HookScope = scope === "local" ? "project" : (scope ?? "user"); + return { + type: "command", + file: `${eventType}${CLINE_HOOK_FILE_EXTENSION}`, + command: `${ + effective === "project" ? "npx -y failproofai" : `"${binaryPath}"` + } --hook ${eventType} --cli cline`, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook(hook) { + if (!hook || typeof hook !== "object") return false; + return (hook as Record)[FAILPROOFAI_HOOK_MARKER] === true; + }, + + writeHookEntries(settings, binaryPath, scope) { + const effective: HookScope = scope === "local" ? "project" : (scope ?? "user"); + const dir = clineHooksDir(effective); + mkdirSync(dir, { recursive: true }); + for (const eventType of CLINE_HOOK_EVENT_TYPES) { + const file = resolve(dir, `${eventType}${CLINE_HOOK_FILE_EXTENSION}`); + // Refuse to clobber a hook we did not write. A hand-written PreToolUse.sh + // is someone's enforcement too, and overwriting it is the same class of + // damage as deleting it. + if (existsSync(file)) { + let existing = ""; + try { + existing = readFileSync(file, "utf8"); + } catch { + /* unreadable — treat as foreign and leave alone */ + continue; + } + if (!existing.includes(FAILPROOFAI_HOOK_MARKER)) continue; + } + writeFileSync(file, buildClineHookScript(binaryPath, eventType, effective), "utf8"); + // cline does not require the executable bit (it picks an interpreter from + // the shebang or the extension), but set it anyway: harmless, and it keeps + // the file usable if cline ever execs directly. + try { + chmodSync(file, 0o755); + } catch { + /* best effort */ + } + } + const s = settings as Record; + s.hooks = CLINE_HOOK_EVENT_TYPES.map((e) => this.buildHookEntry(binaryPath, e, effective)); + }, + + removeHooksFromFile(settingsPath) { + const dir = dirname(settingsPath); + let removed = 0; + for (const eventType of CLINE_HOOK_EVENT_TYPES) { + const file = resolve(dir, `${eventType}${CLINE_HOOK_FILE_EXTENSION}`); + if (!existsSync(file)) continue; + let body = ""; + try { + body = readFileSync(file, "utf8"); + } catch { + continue; + } + if (!body.includes(FAILPROOFAI_HOOK_MARKER)) continue; // not ours — leave it + try { + unlinkSync(file); + removed++; + } catch { + /* already gone */ + } + } + // NEVER remove `dir`. Unlike ori's feature directory, this one is SHARED + // with the user's own hooks. + const settings = this.readSettings(settingsPath) as Record; + delete settings.hooks; + this.writeSettings(settingsPath, settings); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const file = resolve( + clineHooksDir(scope === "local" ? "project" : scope, cwd), + `PreToolUse${CLINE_HOOK_FILE_EXTENSION}`, + ); + if (!existsSync(file)) return false; + try { + return readFileSync(file, "utf8").includes(FAILPROOFAI_HOOK_MARKER); + } catch { + return false; + } + }, + + detectInstalled() { + return binaryExists("cline"); + }, +}; + +// ── Registry ────────────────────────────────────────────────────────────── // `Partial` is kept (not every IntegrationType is guaranteed installable for // LIVE hooks / Pillar 1) so a future audit-only CLI can omit its entry without a // type error. `hermes` now has BOTH an audit adapter // (src/audit/cli-adapters/hermes.ts) AND live-hook install support, so it is // registered here. +// ── grok (xAI grok CLI) integration ───────────────────────────────────────── +// +// grok reads Claude's NESTED hook schema out of its own directory: every +// `*.json` under `~/.grok/hooks/` (user, always trusted) or +// `/.grok/hooks/` (project). We own one file, `failproofai.json`, so the +// install never has to merge with a user's other hook files — the same +// arrangement as Copilot's `.github/hooks/failproofai.json`. +// +// `timeout` is in SECONDS (grok's default is 5, which is too tight for a cold +// `npx` start; 30 matches what the other second-based CLIs use here). +// +// TWO project-scope conditions that are NOT in grok's docs and were found by +// probing grok 1.0.3 — both silently produce a config that never fires: +// • the directory must be a GIT REPO (a trusted non-git dir logs +// `project_sources=0`), and +// • the folder must be TRUSTED (`grok --trust` or `/hooks-trust`). +// `projectScopeWarning()` below reports the first; the second is surfaced in +// the install output, since neither has any other detector. +// +// Deny/instruct semantics live in policy-evaluator.ts's `cli === "grok"` branch +// (grok's OWN `{decision:"deny"}` / `{decision:"block"}` shapes — it ignores +// Claude's hookSpecificOutput deny, verified by A/B on a live session). +const grok: Integration = { + id: "grok", + displayName: "grok CLI", + scopes: GROK_HOOK_SCOPES, + eventTypes: GROK_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "user": + return resolve(homedir(), ".grok", "hooks", "failproofai.json"); + case "project": + case "local": + // grok has no "local" scope; fall back to project so callers don't crash. + return resolve(base, ".grok", "hooks", "failproofai.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const command = + scope === "project" + ? `npx -y failproofai --hook ${eventType} --cli grok` + : `"${binaryPath}" --hook ${eventType} --cli grok`; + return { + type: "command", + command, + // grok reads `timeout` in SECONDS. Its own default is 5s (600s for + // Stop/SubagentStop); 30 leaves room for a cold `npx` start without + // stalling the UI, and a timed-out hook fails OPEN on grok. + timeout: 30, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook: isMarkedHook, + + writeHookEntries(settings, binaryPath, scope) { + const s = settings as ClaudeSettings; + if (!s.hooks) s.hooks = {}; + + for (const eventType of GROK_HOOK_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]; + + let found = false; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record)); + if (idx >= 0) { + matcher.hooks[idx] = hookEntry; + found = true; + break; + } + } + // No `matcher` key: an omitted matcher matches every tool. Do NOT write + // `"*"` here — grok treats a matcher as a REGEX, and the same bare `"*"` + // is an invalid pattern that matches nothing on Goose. + if (!found) matchers.push({ hooks: [hookEntry] }); + } + }, + + removeHooksFromFile(settingsPath) { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return false; + for (const matchers of Object.values(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + if (matcher.hooks.some((h) => isMarkedHook(h as Record))) return true; + } + } + } catch { + // Corrupt settings — treat as not installed + } + return false; + }, + + detectInstalled() { + return binaryExists("grok"); + }, +}; + +// ── qwen (Qwen Code) integration ──────────────────────────────────────────── +// +// qwen keeps hooks under a Claude-style `"hooks"` key inside its normal +// settings file, which also holds `model`, `modelProviders`, auth, etc. — so +// reads/writes go through the merge-preserving readJsonFile/writeJsonFile +// helpers (like Claude/Copilot/Devin), never a whole-file replace. +// user → ~/.qwen/settings.json +// project → /.qwen/settings.json +// +// The one shape difference from every other integration: `timeout` is in +// MILLISECONDS (qwen's default is 60000). Do not "unify" it with the +// seconds-based CLIs — 30 would mean 30ms and every hook would time out. +// +// Deny is Claude's own `hookSpecificOutput.permissionDecision` on PreToolUse +// (so no evaluator branch is needed for it); only Stop diverges. See the +// `cli === "qwen"` branch in policy-evaluator.ts. +const qwen: Integration = { + id: "qwen", + displayName: "Qwen Code", + scopes: QWEN_HOOK_SCOPES, + eventTypes: QWEN_HOOK_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "user": + return resolve(homedir(), ".qwen", "settings.json"); + case "project": + case "local": + // qwen has no "local" scope; fall back to project so callers don't crash. + return resolve(base, ".qwen", "settings.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const command = + scope === "project" + ? `npx -y failproofai --hook ${eventType} --cli qwen` + : `"${binaryPath}" --hook ${eventType} --cli qwen`; + return { + type: "command", + command, + name: `failproofai-${eventType}`, + // MILLISECONDS — qwen is the only integration that is not seconds-based. + // 30000 = 30s, matching the wall-clock budget the others use. + timeout: 30000, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook: isMarkedHook, + + writeHookEntries(settings, binaryPath, scope) { + const s = settings as ClaudeSettings; + if (!s.hooks) s.hooks = {}; + + for (const eventType of QWEN_HOOK_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]; + + let found = false; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record)); + if (idx >= 0) { + matcher.hooks[idx] = hookEntry; + found = true; + break; + } + } + // Matcher omitted = match every tool. qwen also accepts `""`/`"*"`, but an + // omitted key is the one form that is unambiguous across all of them. + if (!found) matchers.push({ hooks: [hookEntry] }); + } + }, + + removeHooksFromFile(settingsPath) { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as ClaudeSettings; + if (!settings.hooks) return false; + for (const matchers of Object.values(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + if (matcher.hooks.some((h) => isMarkedHook(h as Record))) return true; + } + } + } catch { + // Corrupt settings — treat as not installed + } + return false; + }, + + detectInstalled() { + return binaryExists("qwen"); + }, +}; + const INTEGRATIONS: Partial> = { claude: claudeCode, codex, @@ -2295,6 +3045,10 @@ const INTEGRATIONS: Partial> = { devin, antigravity, goose, + grok, + qwen, + ori, + cline, }; export function getIntegration(id: IntegrationType): Integration { diff --git a/src/hooks/normalize-cli-payload.ts b/src/hooks/normalize-cli-payload.ts index b501b9043..125f0055c 100644 --- a/src/hooks/normalize-cli-payload.ts +++ b/src/hooks/normalize-cli-payload.ts @@ -10,6 +10,56 @@ */ import type { IntegrationType } from "./types"; +/** + * True when `parsed` carries grok's camelCase envelope. + * + * This exists because grok EXECUTES OTHER CLIS' HOOK CONFIGS. Its discovery + * scans `~/.claude/settings.json`, `~/.claude/settings.local.json`, + * `/.claude/settings.json` and the `.cursor` equivalents by default + * (`[compat.claude] hooks = true`) — and `/.claude/settings.json` is + * exactly the file `policies --install --cli claude --scope project` writes. So + * on any machine with both tools, grok runs OUR hooks and hands them `--cli + * claude` on the command line while piping ITS OWN camelCase payload. + * + * Verified live against grok 1.0.3: the hook fired, `tool_name` and + * `tool_input` were both undefined, and every builtin that reads either — + * block-sudo, block-env-files, block-secrets-write, block-force-push — allowed. + * Installed, running, costing latency, enforcing nothing; strictly worse than + * no coverage, because the install reports success. Detecting the envelope lets + * handler.ts re-route the event onto grok's path (tool maps AND response + * shapes — grok ignores Claude's `hookSpecificOutput.permissionDecision`, also + * verified by A/B). + * + * The test is on the PAYLOAD, never on an env var: `GROK_HOOK_EVENT` is set by + * grok's runner but is still just an env var, and mistaking a real Claude event + * for a grok one would break Claude's own enforcement. `workspaceRoot` + + * `hookEventName` with no `hook_event_name` is a shape Claude never sends. + */ +export function isGrokEnvelope(parsed: Record): boolean { + return ( + typeof parsed.hookEventName === "string" && + typeof parsed.workspaceRoot === "string" && + parsed.hook_event_name === undefined + ); +} + +/** + * The CLI whose contract actually governs this event. + * + * Identical to the declared `--cli` for every integration except the grok + * cross-execution case above, where the flag says `claude` and the wire says + * grok. Returning "grok" there routes the rest of the pipeline — tool-name and + * tool-input canonicalization, and the response shape policy-evaluator emits — + * onto the contract that will actually be honored. + */ +export function resolveEffectiveCli( + cli: IntegrationType, + parsed: Record, +): IntegrationType { + if (cli === "claude" && isGrokEnvelope(parsed)) return "grok"; + return cli; +} + export function normalizeCliPayload(cli: IntegrationType, parsed: Record): void { // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields // the handler downstream reads to canonical snake_case BEFORE any @@ -47,6 +97,33 @@ export function normalizeCliPayload(cli: IntegrationType, parsed: Record; + if (typeof t.name === "string") parsed.tool_name = t.name; + if (t.input !== undefined) parsed.tool_input = t.input; + } + const pre = parsed.preToolUse; + if (pre && typeof pre === "object" && !Array.isArray(pre)) { + const pp = pre as Record; + if (parsed.tool_name === undefined && typeof pp.toolName === "string") { + parsed.tool_name = pp.toolName; + } + if (parsed.tool_input === undefined && pp.parameters !== undefined) { + parsed.tool_input = pp.parameters; + } + } + // taskId is the conversation ("conv_…"). sessionContext.rootSessionId and + // agent_id / parent_agent_id stay raw for custom policies correlating a + // subagent back to its root. + if (typeof parsed.taskId === "string") parsed.session_id = parsed.taskId; + const roots = parsed.workspaceRoots; + if (Array.isArray(roots) && typeof roots[0] === "string" && roots[0].length > 0) { + parsed.cwd = roots[0]; + } else { + const wi = parsed.workspaceInfo; + if (wi && typeof wi === "object" && !Array.isArray(wi)) { + const rp = (wi as Record).rootPath; + if (typeof rp === "string" && rp.length > 0) parsed.cwd = rp; + } + } + } } diff --git a/src/hooks/policy-evaluator.ts b/src/hooks/policy-evaluator.ts index 42a979707..ac93b60fe 100644 --- a/src/hooks/policy-evaluator.ts +++ b/src/hooks/policy-evaluator.ts @@ -27,7 +27,45 @@ function appendHint(baseReason: string, hint: unknown): string { return `${base}. ${normalizedHint}`; } +/** Which element of a batch this evaluation is for. Absent for every non-batch + * call — which is every other CLI, and every cline event that is not a + * fan-out tool. */ +export interface BatchContext { + index: number; + count: number; + label: string; + degraded: boolean; + /** Every element's canonical input, so a batch-aware policy sees the set. */ + all: Array>; + /** The un-expanded original tool input. */ + raw: Record; +} + +export interface EvaluateOptions { + /** Per-INVOCATION telemetry dedupe set, shared across every element of a + * batch fan-out. Without it a 12-element batch fires 12 copies of the same + * policy_evaluation_error for one real tool call. */ + dedupe?: Set; + /** Set when this call is one element of an expanded batch. */ + batch?: BatchContext; + /** Return the raw instruct/allow entries instead of shaping them; the batch + * combiner shapes ONCE at the end. A DENY is unaffected — it short-circuits + * and is returned fully shaped, because it is the answer. */ + collectEntries?: boolean; + /** Skip the policy loop entirely and shape THESE entries. This is why no + * shaping code had to move: the existing per-CLI instruct/allow tails run in + * place, with the real eventType, session and payload. */ + preEvaluated?: { + instructEntries: Array<{ policyName: string; reason: string }>; + allowEntries: Array<{ policyName: string; reason: string }>; + }; +} + export interface EvaluationResult { + /** Raw instruct entries, returned ONLY when opts.collectEntries is set. */ + instructEntries?: Array<{ policyName: string; reason: string }>; + /** Raw allow-note entries, returned ONLY when opts.collectEntries is set. */ + allowEntries?: Array<{ policyName: string; reason: string }>; exitCode: number; stdout: string; stderr: string; @@ -130,6 +168,7 @@ export async function evaluatePolicies( payload: Record, session?: SessionMetadata, config?: HooksConfig, + opts?: EvaluateOptions, ): Promise { const toolName = payload.tool_name as string | undefined; const toolInput = payload.tool_input as Record | undefined; @@ -138,7 +177,7 @@ export async function evaluatePolicies( hookLogInfo(`evaluating ${policies.length} policies for ${eventType}`); - if (policies.length === 0) { + if (policies.length === 0 && !opts?.preEvaluated) { return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" }; } @@ -151,13 +190,18 @@ export async function evaluatePolicies( cli: session?.cli, }; - // Track all instruct results (accumulated, does not short-circuit) - const instructEntries: Array<{ policyName: string; reason: string }> = []; + // Track all instruct results (accumulated, does not short-circuit). + // Seeded from opts.preEvaluated on the batch combiner's final shaping call. + const instructEntries: Array<{ policyName: string; reason: string }> = [ + ...(opts?.preEvaluated?.instructEntries ?? []), + ]; // Track informational messages from allow decisions (with policy attribution) - const allowEntries: Array<{ policyName: string; reason: string }> = []; + const allowEntries: Array<{ policyName: string; reason: string }> = [ + ...(opts?.preEvaluated?.allowEntries ?? []), + ]; - for (const policy of policies) { + for (const policy of opts?.preEvaluated ? [] : policies) { // Inject params: merge policyParams[policy.name] over schema defaults. // policy.name is canonical (e.g. "failproofai/block-force-push"); user // config keys may be flat or canonical — getConfigParamsFor accepts both. @@ -235,7 +279,17 @@ export async function evaluatePolicies( } else { displayTool = "operation"; } - const blockedMessage = `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`; + // One locator, built once, at the single site every CLI's deny shape + // interpolates — so cline's {cancel,errorMessage} names the offending + // element for free and nothing else needs to know a batch happened. + const batchLocator = opts?.batch + ? ` [batch ${opts.batch.index + 1}/${opts.batch.count}: ${ + opts.batch.label.length > 120 + ? `${opts.batch.label.slice(0, 117)}…` + : opts.batch.label + }]` + : ""; + const blockedMessage = `Blocked ${displayTool}${batchLocator} by failproofai because: ${reason}, as per the policy configured by the user`; // Cursor's hook protocol expects a flat `{permission, user_message, // agent_message}` shape for any blocking decision, regardless of which @@ -378,6 +432,58 @@ export async function evaluatePolicies( }; } + // Ori: the generated ori feature parses a flat {permission, reason} and + // returns ori's own {outcome:"allow"|"deny"} to the approval extension + // point. There is exactly one event (PreToolUse) — ori exposes no Stop, + // prompt, post-tool or session gate — so unlike Pi/OpenClaw there is no + // Stop special case to write. The reason is carried for the operator's + // logs only: ori's verdict shape has no reason field, so a denial reaches + // the model as a bare tool failure. + if (session?.cli === "ori") { + return { + exitCode: 0, + stdout: JSON.stringify({ permission: "deny", reason: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + + // Cline: the installed launcher relays this object verbatim on stdout, and + // cline IGNORES THE EXIT CODE — the JSON is the entire verdict. The only + // blocking shape its schema accepts is {cancel, errorMessage}; there is no + // decision/block/permissionDecision field, so every generic branch below + // is inert for cline and this one must come first. + // + // Stop / StopFailure are deliberately NOT a cancel: `cancel:true` becomes + // {stop:true} -> ControlledStopError -> the whole run aborts, and at + // TaskComplete the task has ALREADY finished, so that kills a completed + // run instead of re-entering the loop. Destructive and useless, hence the + // context channel there and `Stop: "observe"` in enforcement-capability. + if (session?.cli === "cline") { + if (eventType === "Stop" || eventType === "StopFailure") { + return { + exitCode: 0, + stdout: JSON.stringify({ + context: `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}`, + }), + stderr: `[failproofai] ${policy.name}: ${reason}\n`, + policyName: policy.name, + reason, + decision: "deny", + }; + } + return { + exitCode: 0, + stdout: JSON.stringify({ cancel: true, errorMessage: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + // OpenCode: `session.idle` is a notification-only bus event — by the // time the plugin handler fires, OpenCode has already gone idle and // throwing from the handler does not force-retry. The only working @@ -505,6 +611,91 @@ export async function evaluatePolicies( }; } + // grok: deny is `{decision:"deny", reason}` on stdout at exit 0 — verified + // live against grok 1.0.3, and it beat `--yolo` (bypassPermissions). grok + // does NOT read Claude's hookSpecificOutput shape: the same hook emitting + // Claude's shape let the command run, emitting this one blocked it. + // + // Stop takes `{decision:"block", reason}` instead (Claude's turn-end + // vocabulary, which grok shares) — but ONLY on a real turn end. grok fires + // Stop a second time at session shutdown (`reason: "shutdown"`) and + // explicitly discards that decision, so blocking there would record + // enforcement that cannot happen. Falling through to allow is correct: + // the turn is already over. grok caps continuations at 8 per turn. + if (session?.cli === "grok") { + if (eventType === "Stop" || eventType === "SubagentStop") { + const grokStopReason = typeof payload.reason === "string" ? payload.reason : undefined; + if (eventType === "Stop" && grokStopReason && grokStopReason !== "end_turn") { + return { + exitCode: 0, + stdout: "", + stderr: "", + policyName: policy.name, + reason, + decision: "allow", + }; + } + const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`; + return { + exitCode: 0, + stdout: JSON.stringify({ decision: "block", reason: reasonText }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + return { + exitCode: 0, + stdout: JSON.stringify({ decision: "deny", reason: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + + // qwen: PreToolUse honors Claude's own + // `hookSpecificOutput.permissionDecision` shape (verified live — it beat + // `-y`), so that event deliberately falls through to the generic Claude + // branch below rather than being duplicated here. Stop is the divergence: + // it reads the top-level `{decision:"block", reason}` instead, which is + // what forces another turn (verified live — the agent ran the required + // command and only then finished). Unlike grok there is no session-end + // Stop fire to filter out. + // qwen's todo hooks (canonical TaskCreated / TaskCompleted) are the only + // events in the widened set that can actually veto. They read the + // top-level `{decision:"block", reason}` — NOT Claude's permissionDecision + // — and only during their `validation` phase; in `postWrite` the todo is + // already persisted and upstream ignores the block. Emitting it + // unconditionally is right: it enforces where it can and is inert where it + // cannot, and the phase is upstream's to decide, not ours to guess. + if ( + session?.cli === "qwen" && + (eventType === "TaskCreated" || eventType === "TaskCompleted") + ) { + return { + exitCode: 0, + stdout: JSON.stringify({ decision: "block", reason: blockedMessage }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + + if (session?.cli === "qwen" && (eventType === "Stop" || eventType === "SubagentStop")) { + const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`; + return { + exitCode: 0, + stdout: JSON.stringify({ decision: "block", reason: reasonText }), + stderr: "", + policyName: policy.name, + reason, + decision: "deny", + }; + } + if (eventType === "PreToolUse") { const response = { hookSpecificOutput: { @@ -693,6 +884,21 @@ export async function evaluatePolicies( } } + // The batch combiner asked for RAW entries; it dedupes the union across all + // elements and shapes it once, through these same tails, on a final call. + if (opts?.collectEntries) { + return { + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + instructEntries, + allowEntries, + }; + } + // No deny — check if we accumulated any instructs if (instructEntries.length > 0) { const combined = instructEntries.map((e) => e.reason).join("\n"); @@ -980,6 +1186,44 @@ export async function evaluatePolicies( }; } + // Ori: the approval extension points return {outcome:"allow"|"deny"} and + // nothing else — there is no additional-context channel to carry a + // directive, and no Stop event to convert one into a retry. So instruct() + // allows the action and writes the directive to stderr for the operator's + // logs; the model never sees it. Same degradation as Goose and Hermes. + if (session?.cli === "ori") { + const stderrMsg = instructEntries + .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`) + .join("\n"); + return { + exitCode: 0, + stdout: "", + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "instruct", + }; + } + + // Cline HAS a real additional-context channel — `context` is in its verdict + // schema and parseHookControl reads it — so unlike Goose/Hermes/ori this + // does not have to degrade to a stderr note. It is mirrored to stderr as + // well: the field is verified-PARSED by cline, but that the model actually + // sees it is unprobed, and the operator's log should not depend on that. + if (session?.cli === "cline") { + return { + exitCode: 0, + stdout: JSON.stringify({ context: `Instruction from failproofai: ${combined}` }), + stderr: + instructEntries.map((e) => `[failproofai] ${e.policyName}: ${e.reason}`).join("\n") + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "instruct", + }; + } + if (eventType === "Stop" || eventType === "SubagentStop") { // Stop/SubagentStop instruct: exitCode 2 + stderr forces Claude to retry // the agent (or subagent) loop with the reason as context. Same widening @@ -1102,6 +1346,53 @@ export async function evaluatePolicies( }; } + // grok: instruct has a channel only at the turn boundary. On Stop we reuse + // the verified `{decision:"block", reason}` force-retry shape so the + // instruction actually reaches the model (grok also documents a softer + // `hookSpecificOutput.additionalContext` for Stop, but that path is not + // verified and "block" is). The `end_turn` guard is the same one the deny + // branch uses — the session-shutdown fire has no turn left to instruct. + // On every other event grok has no additional-context channel, so instruct + // degrades to allow + a stderr note (like Hermes/Goose/Factory); emitting + // Claude's hookSpecificOutput there would be a shape grok discards. + // + // qwen deliberately has NO branch here: it honors Claude's + // `hookSpecificOutput.additionalContext` on PreToolUse/PostToolUse/ + // UserPromptSubmit (wrapping it in a `` + // provenance tag), so the generic path below is already correct for it. + if (session?.cli === "grok") { + const stderrMsg = allowEntries + .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`) + .join("\n"); + const grokStopReason = typeof payload.reason === "string" ? payload.reason : undefined; + const atRealTurnEnd = + eventType === "SubagentStop" || + (eventType === "Stop" && (!grokStopReason || grokStopReason === "end_turn")); + if (atRealTurnEnd) { + return { + exitCode: 0, + stdout: JSON.stringify({ + decision: "block", + reason: `Instruction from failproofai: ${combined}`, + }), + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "instruct", + }; + } + return { + exitCode: 0, + stdout: "", + stderr: stderrMsg + "\n", + policyName: policyNames[0], + policyNames, + reason: combined, + decision: "allow", + }; + } + const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || diff --git a/src/hooks/resolve-transcript-path.ts b/src/hooks/resolve-transcript-path.ts index 6a19e3261..df73a5e89 100644 --- a/src/hooks/resolve-transcript-path.ts +++ b/src/hooks/resolve-transcript-path.ts @@ -31,6 +31,8 @@ import { findCodexTranscript } from "../../lib/codex-sessions"; import { findCopilotTranscript } from "../../lib/copilot-sessions"; import { findCursorTranscript } from "../../lib/cursor-sessions"; import { findPiTranscript } from "../../lib/pi-sessions"; +import { findGrokTranscript } from "../../lib/grok-sessions"; +import { findQwenTranscript } from "../../lib/qwen-sessions"; import { findFactoryTranscript } from "../../lib/factory-sessions"; import { findAntigravityTranscript } from "../../lib/antigravity-sessions"; import type { IntegrationType } from "./types"; @@ -74,6 +76,14 @@ export function resolveTranscriptPath( // its live-hook payload carries no transcript file, so hand back a virtual // path (like devin) — audit/download read the DB directly. return `goose-db://${sessionId}`; + case "grok": + // grok writes real JSONL at ~/.grok/sessions// + // /chat_history.jsonl. + return findGrokTranscript(sessionId) ?? undefined; + case "qwen": + // qwen writes real JSONL at ~/.qwen/projects//chats/ + // .jsonl (note the extra `chats/` level vs Claude/Factory). + return findQwenTranscript(sessionId) ?? undefined; case "opencode": return `opencode-db://${sessionId}`; case "hermes": diff --git a/src/hooks/risk-patterns.ts b/src/hooks/risk-patterns.ts new file mode 100644 index 000000000..12da7386b --- /dev/null +++ b/src/hooks/risk-patterns.ts @@ -0,0 +1,26 @@ +/** + * Path-risk regexes, shared by the builtins that enforce on them and by the + * batch-collapse fallback that has to decide WHICH path survives a collapse. + * + * They live here rather than in builtin-policies.ts for two reasons: + * + * 1. A second hand-maintained copy is exactly how a bypass gets reintroduced: + * someone tightens `SECRET_FILE_RE` in the builtin and the collapse + * fallback keeps probing the old shape, so the riskiest path stops being + * picked and a `.pem` write rides through on a batch. + * 2. `tool-name-canonicalize.ts` is on the AUDIT path, and builtin-policies.ts + * imports `node:child_process`. Importing the builtins from there to reach + * four regexes would drag the policy engine into transcript replay. + * + * Zero imports, on purpose. Keep it that way. + */ + +/** `.env`, `.env.local`, `path/to/.env` — but not `something.environment`. */ +export const ENV_FILE_PATH_RE = /(?:^|[\\/])\.env(?:\.|$)/; + +/** NOTE the `$` anchor: this can only ever match at the END of a string, which + * is why joining a list of paths and testing the join is a SILENT BYPASS — + * only the last element could match. See pickRiskiestPath in batch-expand.ts. */ +export const SECRET_FILE_RE = /\.(?:pem|key)$/; +export const SECRET_FILE_ID_RSA_RE = /id_rsa/; +export const SECRET_FILE_CREDENTIALS_RE = /credentials/; diff --git a/src/hooks/tool-name-canonicalize.ts b/src/hooks/tool-name-canonicalize.ts index b784d5d91..073e79747 100644 --- a/src/hooks/tool-name-canonicalize.ts +++ b/src/hooks/tool-name-canonicalize.ts @@ -25,7 +25,14 @@ import { ANTIGRAVITY_TOOL_INPUT_MAP, GOOSE_TOOL_MAP, GOOSE_TOOL_INPUT_MAP, + GROK_TOOL_MAP, + GROK_TOOL_INPUT_MAP, + QWEN_TOOL_MAP, + ORI_TOOL_MAP, + ORI_TOOL_INPUT_MAP, + CLINE_TOOL_MAP, } from "./types"; +import { canonicalizeClineToolInput, applyPatchFilePaths } from "./batch-expand"; /** * Canonicalize a per-CLI tool name to the Claude PascalCase form that builtin @@ -56,6 +63,18 @@ export function canonicalizeToolName( // Goose: shell→Bash, write/edit/view→file ops, todo__todo_write→TodoWrite, … // (verified live against goose v1.43.0). Handles bare + `__` names. if (cli === "goose") return GOOSE_TOOL_MAP[raw] ?? raw; + // grok: run_terminal_command→Bash, write/read_file/search_replace→file ops, … + // (every entry observed on the wire against grok 1.0.3). + if (cli === "grok") return GROK_TOOL_MAP[raw] ?? raw; + // qwen: run_shell_command→Bash, write_file/read_file/edit→file ops, … + // (verified live against qwen-code 0.21.12). + if (cli === "qwen") return QWEN_TOOL_MAP[raw] ?? raw; + // Ori: tool ids arrive lowercase (bash/read/write/edit/glob/grep) — all six + // captured at the live approval gate on ori 0.12.0+68f9a36. + if (cli === "ori") return ORI_TOOL_MAP[raw] ?? raw; + // Cline: run_commands/read_files/search_codebase/apply_patch — all four + // observed live on cline v3.0.60's PreToolUse payload. + if (cli === "cline") return CLINE_TOOL_MAP[raw] ?? raw; return raw; } @@ -95,6 +114,26 @@ export function canonicalizeToolInput( // Goose file tools (write/edit/view) deliver the path as `path`, read_image as // `source`; map to `file_path` so path builtins fire (verified goose v1.43.0). else if (cli === "goose") perToolMap = GOOSE_TOOL_INPUT_MAP[toolName]; + // grok's read_file delivers `target_file` and list_dir `target_directory`; + // every other grok tool is already canonical. Without the Read entry a live + // `.env` read walks past block-env-files (verified grok 1.0.3). qwen needs no + // entry at all — all six of its tools deliver canonical keys. + else if (cli === "grok") perToolMap = GROK_TOOL_INPUT_MAP[toolName]; + // Ori: read/write deliver the path as `path`; bash/glob/grep are already + // canonical. `edit` is special — its path lives INSIDE the patch blob and is + // derived below rather than renamed, because there is no key to rename. + else if (cli === "ori") { + if (toolName === "Edit") return canonicalizeOriEditInput(rawInput as Record); + perToolMap = ORI_TOOL_INPUT_MAP[toolName]; + } + // Cline has NO key-rename map: every tool is batch- or blob-shaped, so this + // is a value-SHAPE transform. It is the SAFETY NET for the paths that do not + // fan out (PostToolUse, audit replay, fail-closed shaping); PreToolUse + // enforces through expandBatchToolInput instead, because a collapse cannot + // make an anchored regex match every element. + else if (cli === "cline") { + return canonicalizeClineToolInput(toolName, rawInput as Record); + } if (!perToolMap) return rawInput; const out: Record = {}; for (const [k, v] of Object.entries(rawInput as Record)) { @@ -102,3 +141,28 @@ export function canonicalizeToolInput( } return out; } + +/** + * Recover the file path(s) from an OpenAI apply_patch blob. + * + * Kept as the ori-facing NAME so existing callers and + * __tests__/hooks/ori-canonicalize.test.ts keep working, but the implementation + * now lives in batch-expand.ts and is shared: ori's `edit` and cline's + * `apply_patch` carry byte-identical blobs, and one implementation is what + * stops a third CLI adding a third regex. + * + * NOTE for a follow-up: `splitApplyPatch` (same module) now returns per-file + * old/new text as well, which closes ori's documented multi-file KNOWN GAP — + * and a worse one the gap comment understates, that ori's Edit sets NO + * old_string/new_string at all, so block-secrets-write can never fire on it. + * Deliberately NOT changed here: ori's behaviour stays as shipped in this PR. + */ +export const oriPatchFilePaths = applyPatchFilePaths; + +function canonicalizeOriEditInput(input: Record): Record { + const patch = input.patch; + if (typeof patch !== "string") return input; + const paths = oriPatchFilePaths(patch); + if (paths.length === 0) return input; + return { ...input, file_path: paths[0], ori_patch_files: paths }; +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts index fd2ff7f3a..6648e6a87 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -19,7 +19,7 @@ export const HOOK_SCOPES = ["user", "project", "local"] as const; export type HookScope = (typeof HOOK_SCOPES)[number]; -export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const; +export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "grok", "qwen", "ori", "cline"] as const; export type IntegrationType = (typeof INTEGRATION_TYPES)[number]; export const CODEX_HOOK_SCOPES = ["user", "project"] as const; @@ -999,6 +999,537 @@ export const GOOSE_TOOL_INPUT_MAP: Record> = { LS: { path: "file_path" }, }; +// --------------------------------------------------------------------------- +// grok (xAI's `grok` CLI) — 13th integration. Dual-pillar (live hooks + audit), +// user + project scope, Claude/Codex-style external shell hooks. The entire +// contract below was VERIFIED LIVE against grok 1.0.3 (1a29d5bc12) with a +// recorder hook on all 14 events plus deny / stop-gate probes. +// +// 1. **The envelope is camelCase**, so — like Antigravity — normalizeCliPayload +// has a `grok` branch. `toolName`/`toolInput`/`sessionId`/`transcriptPath` +// → snake_case, `workspaceRoot` → `cwd`, `toolResult` → `tool_response` +// (grok does NOT use Claude's `tool_response`), `stopHookActive` → +// `stop_hook_active`. NOTE `hookEventName`'s *value* is snake_case +// ("pre_tool_use") while the `--hook` arg is PascalCase, so the arg is the +// canonical source and there is NO GROK_EVENT_MAP. +// +// 2. **Deny = `{"decision":"deny","reason"}` on stdout at exit 0.** VERIFIED +// live, and it beat `--yolo` (permissionMode `bypassPermissions`). grok +// does NOT read Claude's `hookSpecificOutput.permissionDecision` shape — +// also verified live, by A/B: the identical hook emitting Claude's shape +// let `echo` run, emitting grok's shape blocked it. This is why +// isGrokEnvelope() exists (see normalize-cli-payload.ts). +// +// 3. **Stop fires TWICE per session** — once per real turn end +// (`reason: "end_turn"`) and once at shutdown (`reason: "shutdown"`), +// whose decision grok parses and then IGNORES (no turn is left to +// continue). So the Stop branch in policy-evaluator.ts gates on +// `reason === "end_turn"`; blocking on the shutdown fire would emit a deny +// that is counted as enforcement and can never be acted on. Captured +// sequence, one turn: end_turn/stopHookActive=false (we blocked) → the +// agent ran the required command → end_turn/stopHookActive=true (allowed) +// → shutdown. Cap: 8 continuations per turn, then grok forces the stop. +// +// 4. **Project hooks require a GIT REPO** — undocumented, verified live: in a +// *trusted* non-git directory holding a valid `.grok/hooks/*.json`, grok +// logs `project_sources=0` and the hook never fires; after `git init` in +// the same directory it logs `project_sources=4` and fires. A project-scope +// install into a non-git dir is a silent no-op, so the installer warns. +// Project scope additionally requires folder trust (`--trust` / +// `/hooks-trust`); user scope (`~/.grok/hooks/`) is always trusted. +// +// Settings paths (VERIFIED): +// user → ~/.grok/hooks/failproofai.json (always trusted) +// project → /.grok/hooks/failproofai.json (needs git + folder trust) +// +// `timeout` is in SECONDS (grok's default is 5; 600 for Stop/SubagentStop). +// Env injected on every hook: GROK_HOOK_EVENT, GROK_HOOK_NAME, GROK_SESSION_ID, +// GROK_WORKSPACE_ROOT, and CLAUDE_PROJECT_DIR (a Claude-compatible alias) — so +// the dogfood config can use $CLAUDE_PROJECT_DIR like .claude/settings.json. +// +// Audit pillar: `~/.grok/sessions///` — +// PERCENT encoding (`%2Fhome%2Fyou%2Frepo`), not Claude's dash style. Each +// session dir holds chat_history.jsonl + events.jsonl + summary.json (the last +// carries `info.cwd`, `session_summary`, `num_messages`, `current_model_id`). +// See lib/grok-sessions.ts. `GROK_HOME` overrides the home dir for tests. +export const GROK_HOOK_SCOPES = ["user", "project"] as const; +export type GrokHookScope = (typeof GROK_HOOK_SCOPES)[number]; + +// All 14 of grok's events, which is its entire surface. Every name here was +// accepted by a live grok 1.0.3 (`hooks: loaded from global source … count=14`, +// `loaded hooks hook_count=14`, no unknown-key warning) — grok silently SKIPS +// unrecognized event keys, so acceptance is the thing to verify, and it was. +// `Notification` and `StopFailure` were additionally observed firing. +// +// The six beyond the original eight are all OBSERVATION — grok's own ACP +// handshake advertises `blockingEvents: ["pre_tool_use","stop","subagent_stop"]` +// and that is the complete list, so nothing added here can ever deny. They are +// installed for custom-policy surface and audit signal, and because the cost of +// an event that never fires is zero. +export const GROK_HOOK_EVENT_TYPES = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionDenied", + "Stop", + "StopFailure", + "Notification", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionEnd", +] as const; +export type GrokHookEventType = (typeof GROK_HOOK_EVENT_TYPES)[number]; + +/** + * grok's tool ids → Claude PascalCase canonical names so existing builtins + * (which match `toolName === "Bash"`) fire unchanged. Every entry below was + * observed on the wire, not read from a doc — which matters here, because + * grok's own docs disagree with themselves: the hooks doc calls the shell tool + * `run_terminal_command` and the headless doc calls it `run_terminal_cmd`. The + * wire says `run_terminal_command`; the alias is kept so a matcher written + * against either name still canonicalizes. Unknown tools pass through via the + * `?? raw` fallback in handler.ts:canonicalizeToolName. + */ +export const GROK_TOOL_MAP: Record = { + run_terminal_command: "Bash", + run_terminal_cmd: "Bash", + write: "Write", + read_file: "Read", + search_replace: "Edit", + grep: "Grep", + list_dir: "LS", + web_search: "WebSearch", + web_fetch: "WebFetch", + spawn_subagent: "Task", +}; + +/** + * Per-tool input-key translation, keyed by the *canonical* tool name. Only two + * of grok's tools deviate, and both were found by capture rather than by + * reading: `read_file` delivers the path as `target_file` and `list_dir` as + * `target_directory`. The `read_file` entry is the load-bearing one — without + * it a live `.env` read sails past block-env-files / block-read-outside-cwd, + * the exact bug COPILOT_TOOL_INPUT_MAP was added to fix. Everything else is + * already canonical: Bash `command`, Write `file_path`/`content`, Edit + * `file_path`/`old_string`/`new_string`, Grep `pattern`/`path`. + */ +export const GROK_TOOL_INPUT_MAP: Record> = { + Read: { target_file: "file_path" }, + LS: { target_directory: "path" }, +}; + +// --------------------------------------------------------------------------- +// qwen (Alibaba's Qwen Code, `qwen`) — 14th integration. Dual-pillar, user + +// project scope. The CHEAPEST integration in the codebase: qwen is a near-pure +// Claude clone on the wire, so it needs NO event map, NO payload normalization, +// and NO tool-input map. Verified live against @qwen-code/qwen-code 0.21.12. +// +// 1. **Payload is pure Claude snake_case** — `hook_event_name` (PascalCase +// *value*, unlike grok), `session_id`, `transcript_path`, `cwd`, +// `permission_mode`, `tool_name`, `tool_input`, `tool_response`, +// `stop_hook_active`. Nothing to normalize. +// +// 2. **Deny = `hookSpecificOutput.permissionDecision`** ("allow" | "deny" | +// "ask"), which is Claude's own PreToolUse shape — so the generic Claude +// branch in policy-evaluator.ts already emits the right thing and qwen +// needs no PreToolUse special-case. VERIFIED live: it beat `-y` (yolo) and +// the reason reached the model verbatim. ("ask" degrades to deny in +// headless and in background subagents.) Stop takes the top-level +// `{decision:"block",reason}` shape instead, which is why the qwen branch +// below exists at all. +// +// 3. **`stop_hook_active` is TRUE on the FIRST Stop fire**, before anything +// has blocked — verified live. It is therefore NOT a usable "already +// retrying" signal on qwen, and no failproofai loop guard may depend on +// it. (Unlike grok, qwen fires no session-end Stop: both fires are real.) +// +// 4. **`UserPromptSubmit` fires per MODEL INVOCATION, not per user prompt** — +// one user turn produced FOUR of them (initial query + one per tool-result +// continuation). qwen's own docs confirm it covers UserQuery/ToolResult/ +// Hook sends and warn that `prompt` is not necessarily user input. Any +// UserPromptSubmit policy fires N× per turn here; `submitted_prompt` is +// present only for interactive-TUI submissions (absent in headless, ACP, +// serve, SDK). +// +// Settings paths (VERIFIED): the `hooks` key inside qwen's normal settings. +// user → ~/.qwen/settings.json +// project → /.qwen/settings.json +// +// `timeout` is in MILLISECONDS (default 60000) — qwen is the ONLY integration +// that is not seconds-based, so buildHookEntry must not be "simplified" to +// share the others' value. `disableAllHooks: true` (top level) and `--safe-mode` +// both disable every hook. +// +// Audit pillar: `~/.qwen/projects//chats/.jsonl` — +// Claude-style encoded-cwd folders, one JSONL per session, lines carrying +// `{sessionId, timestamp, type: user|assistant|system|tool_result, cwd}`. A real +// cwd per line means audit groups by project like Claude/Devin/Goose. See +// lib/qwen-sessions.ts. `QWEN_HOME` overrides the home dir for tests. +export const QWEN_HOOK_SCOPES = ["user", "project"] as const; +export type QwenHookScope = (typeof QWEN_HOOK_SCOPES)[number]; + +// Every event below has a real `executeHooks("")` dispatch site in the +// shipped qwen bundle (verified by reading it, not the docs — which is also how +// `InstructionsLoaded`, `UserPromptExpansion` and `PostToolBatch` turned up: +// all three are dispatched but absent from qwen's documented event table). +// TodoCreated/TodoCompleted/PostToolBatch/InstructionsLoaded/Notification were +// additionally observed firing in a live 0.21.12 session. +// +// Deliberately NOT subscribed: +// • `MessageDisplay` — fires per streaming chunk, i.e. a hook process per +// chunk. The one entry here that could make hooks feel slow. +// • `PostToolBatch` — fired 6× in the same task PostToolUse fired 5×, and +// carries the same tool calls in batch form. Measured at +76% hook +// invocations for a task, against no builtin that reads it. One line to add +// later if a custom policy ever wants batch granularity. +// • `SessionDelete` — no canonical equivalent, and little to enforce on. +export const QWEN_HOOK_EVENT_TYPES = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "PermissionDenied", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", + "InstructionsLoaded", + "UserPromptExpansion", + "TodoCreated", + "TodoCompleted", + "SessionEnd", +] as const; +export type QwenHookEventType = (typeof QWEN_HOOK_EVENT_TYPES)[number]; + +/** + * qwen event name → canonical HookEventType. + * + * Seventeen of nineteen are already canonical and map to themselves; this map + * exists for the two that are not. qwen calls its task list "todos", so + * `TodoCreated`/`TodoCompleted` are the same concept failproofai and Claude + * call `TaskCreated`/`TaskCompleted`, and mapping them lets a policy written + * once fire on both. + * + * These two are also the only ADDED events on either CLI that can actually + * block. qwen runs todo hooks in two phases and the payload says which: + * during `phase: "validation"` a `{decision:"block"|"deny", reason}` prevents + * the write and the reason goes back to the model; during `phase: "postWrite"` + * the todo is already persisted and a block is ignored. Both phases were + * observed live (`phase: "validation"` on every capture). + * + * Exhaustive `Record` so tsc fails the build + * if an event is added here without deciding what it canonicalizes to. + */ +export const QWEN_EVENT_MAP: Record = { + SessionStart: "SessionStart", + UserPromptSubmit: "UserPromptSubmit", + PreToolUse: "PreToolUse", + PostToolUse: "PostToolUse", + PostToolUseFailure: "PostToolUseFailure", + PermissionRequest: "PermissionRequest", + PermissionDenied: "PermissionDenied", + Stop: "Stop", + StopFailure: "StopFailure", + SubagentStart: "SubagentStart", + SubagentStop: "SubagentStop", + PreCompact: "PreCompact", + PostCompact: "PostCompact", + Notification: "Notification", + InstructionsLoaded: "InstructionsLoaded", + UserPromptExpansion: "UserPromptExpansion", + TodoCreated: "TaskCreated", + TodoCompleted: "TaskCompleted", + SessionEnd: "SessionEnd", +}; + +/** + * qwen's runtime tool ids → Claude PascalCase canonical names. All six were + * observed live. qwen also accepts its own display names (`WriteFile`, + * `ReadFile`) as matcher aliases, so those are mapped too for configs written + * against the older names. Unknown tools pass through via the `?? raw` + * fallback. + * + * There is deliberately NO QWEN_TOOL_INPUT_MAP: every tool already delivers + * canonical keys — `run_shell_command` `{command}`, `read_file` `{file_path}`, + * `write_file` `{file_path, content}`, `edit` `{file_path, old_string, + * new_string}`, `grep_search` `{pattern, path}`, `list_directory` `{path}`. + */ +export const QWEN_TOOL_MAP: Record = { + run_shell_command: "Bash", + read_file: "Read", + ReadFile: "Read", + read_many_files: "Read", + write_file: "Write", + WriteFile: "Write", + edit: "Edit", + replace: "Edit", + grep_search: "Grep", + search_file_content: "Grep", + glob: "Glob", + list_directory: "LS", + web_fetch: "WebFetch", + google_web_search: "WebSearch", + task: "Task", + todo_write: "TodoWrite", +}; + + +// ── Ori (OpenRouter's ori) ────────────────────────────────────────────────── +// +// `ori` is two products behind one binary, and only the second is this +// integration: +// +// 1. A LAUNCHER for other agent CLIs (`ori claude`, `ori codex`, `ori grok`, +// `ori opencode`, `ori hermes`, `ori omp`, `ori prime-agent`, `ori kilo`, +// `ori dsh`) that runs the real third-party binary under OpenRouter +// credentials. It injects credentials and NOTHING else — verified live +// against ori 0.12.0+68f9a36: `ori claude` passes `--settings ''` +// carrying only `apiKeyHelper` + `env`; `ori codex` passes `-c +// model_provider=…` key overrides (which override `config.toml` keys, a +// different file from `hooks.json`); `ori grok` and `ori opencode` set env +// vars only; and NONE of them redirect HOME or a config dir. Claude's +// `--settings` MERGES (proven: a project SessionStart hook fired +// identically with and without ori's exact blob) and opencode's +// `OPENCODE_CONFIG_CONTENT` merges too (proven against `opencode debug +// config`: the `plugin` array survived intact and `openrouter` was added +// beside the pre-existing provider). So failproofai's EXISTING per-CLI +// hooks keep enforcing under `ori `, and this integration +// deliberately does nothing for that path. +// +// 2. ori's OWN agent — bare `ori` / `ori code`, the built-in +// `@ori-runloop/agent-loop` harness. THAT is what this integration gates. +// +// Enforcement is via ori's PUBLISHED EXTENSION POINTS, supplied by a feature: a +// workspace package under the global workspace's `features/`, AUTO-DISCOVERED +// with no config file to register it in (like Goose's dropped plugin dir, and +// unlike OpenCode, which must be named in `opencode.json`). failproofai +// generates that feature at `~/.ori/global/features/failproofai/`. USER scope +// only — bare `ori` boots the GLOBAL workspace rather than the project's, so +// one install covers every project and there is no project-scope equivalent. +// +// Three points, each `policy: "unique"` (exactly one provider apiece, so a +// competing feature claiming one displaces us): +// +// approval-policy static {defaultAction, rules[]} — consulted in BOTH modes +// approval-asker dynamic per-call callback — MANUAL mode only +// unattended-approvals dynamic per-call callback — unattended runs +// +// **The mode caveat is the whole story for coverage** (verified live). ori's +// approval mode defaults to `self-drive`, which "approves every command without +// prompting", and in that mode the DYNAMIC points are NEVER CALLED. Proven +// three ways: no callback fired under self-drive; still none after adding +// `approval-policy` with `defaultAction:"ask"` (so `ask` degrades to +// auto-approve); but `defaultAction:"reject"` DID block every tool call — which +// proves the static point is wired and that it is specifically the dynamic +// asker self-drive skips. So failproofai's per-argument policies enforce on ori +// only under `--approvals manual` (or `/approvals` → manual in the TUI). There +// is no config key or env var to change that default: searched `config.json`, +// `ori.md` frontmatter, the `ORI_*` env surface and the shipped selfdev docs. +// `ORI_STATIC_APPROVAL_POLICY` below is what we can still enforce in the +// default mode, and it is deliberately `ask` (a no-op there) rather than +// `reject`, because claiming the point with a blanket reject would brick every +// self-drive session the moment failproofai is installed. +// +// The one thing ori does BETTER than every other integration: both dynamic +// points declare `failureBehavior: "deny"` — "a throwing, rejecting, or +// malformed provider denies the request". **ori fails closed natively**, so a +// failproofai fault blocks the call instead of waving it through. Goose and +// OpenClaw fail open today; everywhere else we built fail-closed ourselves. +// +// The verdict shape is `{outcome: "allow" | "deny"}` — binary, and NO reason +// string reaches the model, so a denial arrives as a bare tool failure. +// `instruct()` therefore degrades to allow + a stderr note. There is no Stop +// event at all, so the 5 `require-*-before-stop` builtins are INAPPLICABLE on +// ori, exactly as on Hermes and Goose. +// +// Gate payload, captured live off real tool calls (ori 0.12.0+68f9a36 driving +// nvidia/nemotron-3.5-lightning:free): +// {tool:"bash", arguments:[{name:"command"}], capabilities:["execute","read","write"]} +// {tool:"read", arguments:[{name:"path"}], capabilities:["read"]} +// {tool:"write", arguments:[{name:"path"},{name:"content"}], capabilities:["write"]} +// {tool:"glob", arguments:[{name:"pattern"}], capabilities:["read"]} +// {tool:"grep", arguments:[{name:"pattern"},{name:"path"}], capabilities:["read"]} +// {tool:"edit", arguments:[{name:"patch"}], capabilities:["read","write"]} +// +// Two properties that bite: +// • `arguments` is a flat name/value STRING array — every value arrives +// stringified, so a policy expecting a structured tool input sees text. +// • The gate fires TWICE per tool call: once with `escalated:false`, then +// again with `escalated:true` plus a synthetic `{name:"escalated", +// value:"true"}` argument. A policy must be idempotent across that ladder. +// +// `edit` is the awkward one: it carries the entire change as a single `patch` +// string in OpenAI apply_patch format (`*** Begin Patch` / `*** Update File: +// ` / `*** End Patch`) with NO separate path argument — so `file_path`, +// which `block-env-files`, `block-secrets-write` and every other path builtin +// reads, is simply absent and those builtins would never fire on an edit. +// `oriPatchFilePaths()` in tool-name-canonicalize.ts recovers it from the patch +// header. KNOWN GAP: a multi-file patch yields several paths and `file_path` +// holds only one, so builtins see the FIRST and a policy that would have denied +// on a later file does not fire; the full list is exposed as `ori_patch_files` +// for custom policies to read. +export const ORI_HOOK_SCOPES = ["user"] as const; +export type OriHookScope = (typeof ORI_HOOK_SCOPES)[number]; + +// Only PreToolUse: all three approval points gate a tool call and nothing else. +// ori has no prompt-submit, post-tool, session or stop hook we can subscribe to +// (its feature-to-feature `hooks` export is documented upstream as "consumer +// wiring and dispatch land in #1068" — i.e. not wired yet). +export const ORI_HOOK_EVENT_TYPES = ["PreToolUse"] as const; +export type OriHookEventType = (typeof ORI_HOOK_EVENT_TYPES)[number]; + +// ori tool ids arrive lowercase. All six below were observed at the live +// approval gate; unknown tools pass through unchanged so they still reach the +// audit, just unmatched by name-keyed builtins. +export const ORI_TOOL_MAP: Record = { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", + glob: "Glob", + grep: "Grep", +}; + +// Keyed by the CANONICAL tool name (the handler canonicalizes the name first). +// `bash`'s `command`, `glob`/`grep`'s `pattern` and `grep`'s `path` are already +// the keys Claude builtins read, so they need no entry; `read`/`write` deliver +// the path as `path`. `Edit` is absent on purpose — its path is inside the +// patch blob, not a key, and is derived instead. +export const ORI_TOOL_INPUT_MAP: Record> = { + Read: { path: "file_path" }, + Write: { path: "file_path" }, +}; + +// Claimed so `approval-policy` is ours (it is `unique`, so leaving it unclaimed +// invites another feature to take it), but deliberately inert: `ask` is what +// self-drive already does. See the mode caveat above for why this is not +// `reject`. +export const ORI_STATIC_APPROVAL_POLICY = { + defaultAction: "ask", + rules: [] as const, +} as const; + + +// ── Cline (cline CLI) ─────────────────────────────────────────────────────── +// +// Cline is the only integration whose config is a DIRECTORY OF EVENT-NAMED +// FILES rather than a settings file. There is no JSON to merge and no array to +// append to: the install drops one launcher script per event, named exactly for +// the event it subscribes to. Verified live against cline v3.0.60. +// +// Three rules from that contract govern everything below: +// +// 1. **THE FILENAME IS THE EVENT**, case-insensitively, and only ten names +// exist. A typo is not an error — it is silence. This is what made an +// earlier probe conclude, wrongly, that cline had no reachable hook +// surface: the files were in the right directory under the wrong names, +// and cline skipped them without a log line. +// 2. **The EXTENSION must be in the allowlist** — `"" .sh .bash .zsh .js .mjs +// .cjs .ts .mts .cts .py .ps1` — or the file is skipped, also silently. A +// `hooks.json` is invisible twice over. The file need NOT be executable; +// the interpreter comes from the shebang, else the extension. +// 3. **THE EXIT CODE IS IGNORED** on every event. The verdict is the single +// JSON object on stdout, so the launcher must ALWAYS print exactly one +// object — `{}` when failproofai produced nothing. Unparseable stdout makes +// cline skip the hook and run the tool. +// +// Hook directories, in cline's own search order: +// ~/Documents/Cline/Hooks +// $CLINE_DIR/hooks (default ~/.cline/hooks) ← user scope +// /.clinerules/hooks ← project scope +// /.cline/hooks +// **`--hooks-dir` IS A DEAD FLAG** in v3.0.60: it writes `CLINE_HOOKS_DIR`, +// which appears exactly once in the shipped binary — that write — and is read by +// nothing. Never emit it. +// +// Deny shape: `{"cancel": true, "errorMessage": "…"}`. Cline's verdict schema is +// `{contextModification?, cancel?, review?, errorMessage?, context?, +// overrideInput?}` — there is NO `decision` / `block` / `permissionDecision` +// field, so every generic branch in policy-evaluator.ts is inert for it. +// `overrideInput` is a real MUTATE channel and `context` a real +// additional-context channel; neither is wired yet. +// +// **FAIL-OPEN, with no way to opt out.** A timeout (120s default), a parse +// failure or a spawn error makes cline skip the hook and run the tool. Unlike +// ori there is no `failureBehavior:"deny"` to inherit, so a failproofai fault on +// cline is a silent allow — which is why the generated launcher prints `{}` on +// any error rather than partial output that might parse as something else. +// +// **THE PRODUCT CAVEAT:** `cancel:true` becomes `{stop:true}` and is applied by +// `applyStopControl`, which THROWS `ControlledStopError` — it ABORTS THE WHOLE +// RUN (observed live as `[abort] aborted by another client`), not just the one +// tool call. It is a STRONGER action than a per-tool deny, not a weaker one. +// +// PreToolUse payload, captured live: +// {"hookName":"tool_call","iteration":1,"taskId":"conv_…","userId":"…", +// "workspaceRoots":["/abs/path"],"workspaceInfo":{"rootPath":"…","hint":"…"}, +// "agent_id":"…","parent_agent_id":null,"sessionContext":{"rootSessionId":"…"}, +// "tool_call":{"id":"call_…","name":"run_commands","input":{"commands":["echo hi"]}}, +// "preToolUse":{"toolName":"run_commands","parameters":{"commands":"[\"echo hi\"]"}}} +// `tool_call.input` is the source of truth; `preToolUse.parameters` +// JSON-STRINGIFIES every array value, so reading it would hand the batch +// expander a string and silently drop the entire fan-out. +export const CLINE_HOOK_SCOPES = ["user", "project"] as const; +export type ClineHookScope = (typeof CLINE_HOOK_SCOPES)[number]; + +// The nine we install. `PreCompact` is one of cline's ten names but is +// DELIBERATELY EXCLUDED: it maps to undefined upstream and is skipped at +// dispatch, so a file for it costs a subprocess per compaction and buys nothing. +export const CLINE_HOOK_EVENT_TYPES = [ + "TaskStart", + "TaskResume", + "TaskCancel", + "TaskComplete", + "TaskError", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "SessionShutdown", +] as const; +export type ClineHookEventType = (typeof CLINE_HOOK_EVENT_TYPES)[number]; + +// These nine strings are simultaneously the `--hook` argument, the installed +// file's basename, and the key here. +export const CLINE_EVENT_MAP: Record = { + TaskStart: "SessionStart", + TaskResume: "SessionStart", // a resumed task is the same session continuing + TaskCancel: "SessionEnd", + TaskComplete: "Stop", + TaskError: "StopFailure", + PreToolUse: "PreToolUse", + PostToolUse: "PostToolUse", + UserPromptSubmit: "UserPromptSubmit", + SessionShutdown: "SessionEnd", +}; + +// Every entry observed LIVE on cline v3.0.60's PreToolUse payload. Unknown tools +// pass through unchanged so they still reach the audit, just unmatched by +// name-keyed builtins. +export const CLINE_TOOL_MAP: Record = { + run_commands: "Bash", + read_files: "Read", + search_codebase: "Grep", + apply_patch: "Edit", +}; + +// **There is deliberately NO CLINE_TOOL_INPUT_MAP.** Do not "fix" the omission +// by adding an empty one. Every cline tool is batch- or blob-shaped, so there is +// no key to RENAME — the whole job is a value-SHAPE transform, and it lives in +// batch-expand.ts: +// run_commands {commands:[…]} → one element per entry, {command} +// read_files {files:[{path}]}→ one element per entry, {file_path} +// search_codebase {queries:[…]} → one element per entry, {pattern} +// apply_patch {input:"blob"} → one element per patched file + export const HOOK_EVENT_TYPES = [ "SessionStart", "SessionEnd", @@ -1093,7 +1624,7 @@ export interface SessionMetadata { * Use this for round-tripping the agent-side event name in response shapes * when stdin doesn't include `hook_event_name`. */ rawHookEventName?: string; - /** Which agent CLI fired this hook (claude | codex | copilot | cursor | opencode | pi | hermes | openclaw | factory | devin | antigravity | goose). Set by handler.ts from --cli. */ + /** Which agent CLI fired this hook (claude | codex | copilot | cursor | opencode | pi | hermes | openclaw | factory | devin | antigravity | goose | ori | cline). Set by handler.ts from --cli. */ cli?: IntegrationType; }