@webpieces/ai-hook-rules 0.4.711 → 0.4.713
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/adapters/detect-ai.d.ts +5 -4
- package/src/adapters/detect-ai.js +5 -4
- package/src/adapters/detect-ai.js.map +1 -1
- package/src/adapters/hook-core.js +9 -3
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/codex-guard-presence.d.ts +86 -0
- package/src/bin/codex-guard-presence.js +150 -0
- package/src/bin/codex-guard-presence.js.map +1 -0
- package/src/bin/codex-trust.d.ts +99 -0
- package/src/bin/codex-trust.js +199 -0
- package/src/bin/codex-trust.js.map +1 -0
- package/src/bin/hook-registration.d.ts +200 -43
- package/src/bin/hook-registration.js +218 -89
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +0 -30
- package/src/bin/l0-allowlist.js +9 -67
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/l0-decide.d.ts +43 -0
- package/src/bin/l0-decide.js +90 -0
- package/src/bin/l0-decide.js.map +1 -0
- package/src/bin/l0-ignored-tools.d.ts +38 -0
- package/src/bin/l0-ignored-tools.js +44 -0
- package/src/bin/l0-ignored-tools.js.map +1 -0
- package/src/bin/setup-config.d.ts +19 -0
- package/src/bin/setup-config.js +370 -0
- package/src/bin/setup-config.js.map +1 -0
- package/src/bin/setup.d.ts +56 -21
- package/src/bin/setup.js +125 -370
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-audit-log.js +10 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.js +4 -4
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.d.ts +2 -0
- package/src/bin/shim.js +21 -1
- package/src/bin/shim.js.map +1 -1
- package/src/bin/upgrade-shim.d.ts +1 -1
- package/src/bin/upgrade-shim.js +12 -8
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/agent-event.d.ts +16 -0
- package/src/core/agent-event.js +17 -1
- package/src/core/agent-event.js.map +1 -1
- package/src/core/ai-type-context.d.ts +36 -0
- package/src/core/ai-type-context.js +45 -0
- package/src/core/ai-type-context.js.map +1 -0
- package/src/core/decision-log.js +9 -0
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l0-matrix.js +14 -6
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l0-tooling-doc.d.ts +3 -2
- package/src/core/l0-tooling-doc.js +22 -10
- package/src/core/l0-tooling-doc.js.map +1 -1
- package/src/core/rejection-log.js +5 -1
- package/src/core/rejection-log.js.map +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +4 -1
- package/src/index.js.map +1 -1
- package/templates/ai-hook.sh +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.713",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.713",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
},
|
|
@@ -17,10 +17,11 @@ import { AiType } from '../core/agent-event';
|
|
|
17
17
|
* cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
|
|
18
18
|
* the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit
|
|
21
|
+
* line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this
|
|
22
|
+
* constant: `committedShimStale()` compares the committed `.claude/webpieces/ai-hook.sh` against
|
|
23
|
+
* `renderShim()` of the INSTALLED release, so the committed shim is NOT regenerated in the same PR that
|
|
24
|
+
* changes the renderer — it is regenerated after that release publishes, or by `wp-upgrade-shim`.
|
|
24
25
|
*/
|
|
25
26
|
export declare const AI_TYPE_TOKEN_SH = "\"turn_id\":";
|
|
26
27
|
/**
|
|
@@ -20,10 +20,11 @@ exports.detectAiType = detectAiType;
|
|
|
20
20
|
* cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
|
|
21
21
|
* the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit
|
|
24
|
+
* line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this
|
|
25
|
+
* constant: `committedShimStale()` compares the committed `.claude/webpieces/ai-hook.sh` against
|
|
26
|
+
* `renderShim()` of the INSTALLED release, so the committed shim is NOT regenerated in the same PR that
|
|
27
|
+
* changes the renderer — it is regenerated after that release publishes, or by `wp-upgrade-shim`.
|
|
27
28
|
*/
|
|
28
29
|
exports.AI_TYPE_TOKEN_SH = '"turn_id":';
|
|
29
30
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;AAyCA,oCAIC;AA3CD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACU,QAAA,gBAAgB,GAAG,YAAY,CAAC;AAE7C;;;;GAIG;AACU,QAAA,UAAU,GAAG,wBAAwB,wBAAgB,2CAA2C,CAAC;AAE9G;;;GAGG;AACH,6NAA6N;AAC7N,2MAA2M;AAC3M,SAAgB,YAAY,CAAC,OAAgB;IACzC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,aAAa,CAAC;IAC1E,kIAAkI;IAClI,OAAO,SAAS,IAAK,OAAmC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,aAAa,CAAC;AACvF,CAAC","sourcesContent":["import { AiType } from '../core/agent-event';\n\n/**\n * THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;\n * Claude Code's has no such key. Everything else in the two envelopes is the same key names\n * (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is\n * exactly why one positive key is the whole test rather than a shape heuristic.\n *\n * Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must\n * answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes\n * text) and this binary (which has the parsed object). That is the same pattern\n * ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts\n * asserts the two agree over a corpus the same way.\n *\n * The sh half is an APPROXIMATION and says so out loud: it matches the six bytes `\"turn_id\":` in the\n * raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling\n * inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser\n * cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and\n * the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.\n *\n * WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit\n * line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this\n * constant: `committedShimStale()` compares the committed `.claude/webpieces/ai-hook.sh` against\n * `renderShim()` of the INSTALLED release, so the committed shim is NOT regenerated in the same PR that\n * changes the renderer — it is regenerated after that release publishes, or by `wp-upgrade-shim`.\n */\nexport const AI_TYPE_TOKEN_SH = '\"turn_id\":';\n\n/**\n * Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values\n * are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte\n * instead of translating between two vocabularies (translation is where twins drift).\n */\nexport const AI_TYPE_SH = `case \"$PAYLOAD\" in *'${AI_TYPE_TOKEN_SH}'*) AI=codex ;; *) AI=claude-code ;; esac`;\n\n/**\n * JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of\n * the top-level envelope?\n */\n// webpieces-disable no-any-unknown -- the argument IS unparsed JSON from another process's stdout; naming a type here would assert a shape we have not yet established, which is the question this function exists to answer\n// webpieces-disable no-function-outside-class -- twin of an sh fragment in the dependency-free adapter layer; it must stay callable on a tree too broken to build a DI container, exactly like isAllowed()\nexport function detectAiType(payload: unknown): AiType {\n if (payload === null || typeof payload !== 'object') return 'claude-code';\n // webpieces-disable no-any-unknown -- narrowing the same unparsed JSON; the index signature is the widest true statement about it\n return 'turn_id' in (payload as Record<string, unknown>) ? 'codex' : 'claude-code';\n}\n"]}
|
|
@@ -23,6 +23,7 @@ const hook_registration_1 = require("../bin/hook-registration");
|
|
|
23
23
|
const shim_deny_reason_1 = require("../bin/shim-deny-reason");
|
|
24
24
|
const l0_matrix_1 = require("../core/l0-matrix");
|
|
25
25
|
const log_stream_1 = require("../core/log-stream");
|
|
26
|
+
const ai_type_context_1 = require("../core/ai-type-context");
|
|
26
27
|
const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
27
28
|
const ADAPTERS = new agent_adapters_1.AgentAdapters();
|
|
28
29
|
const SUBAGENT_GUARD = new codex_subagent_guard_1.CodexSubagentSharedTreeGuard();
|
|
@@ -200,9 +201,10 @@ function enforceCommittedShim(payload, event, cwd, mode) {
|
|
|
200
201
|
const shimRoot = (0, shim_1.governingShimRoot)();
|
|
201
202
|
if (mode === 'rules')
|
|
202
203
|
return;
|
|
203
|
-
// WHICH of the
|
|
204
|
-
//
|
|
205
|
-
//
|
|
204
|
+
// WHICH of the managed things moved — ai-hook.sh, either harness's hook registration
|
|
205
|
+
// (.claude/settings.json, .codex/hooks.json), or the Claude settings' managed env entry (the
|
|
206
|
+
// Bash-cwd pin that keeps a guard's verdict independent of where an earlier `cd` left the shell;
|
|
207
|
+
// see managed-env.ts). Nothing validated the registration before it joined this
|
|
206
208
|
// fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal
|
|
207
209
|
// anywhere — which is the whole reason the registration is a drift surface and not just an install
|
|
208
210
|
// step.
|
|
@@ -267,6 +269,10 @@ function runPipeline(raw, mode) {
|
|
|
267
269
|
// BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why
|
|
268
270
|
// all three of session/agent/hook are needed to keep concurrent writers off one file.
|
|
269
271
|
log_stream_1.logStream.identify(new log_stream_1.StreamIdentity(event.sessionId, event.agentId, mode));
|
|
272
|
+
// Same moment, same reason: every JS-side stream stamps `ai=`, and the fault-S line written
|
|
273
|
+
// inside enforceCommittedShim() is one of them. See AiTypeContext for why the harness is a
|
|
274
|
+
// process-wide holder rather than a parameter on four writers.
|
|
275
|
+
ai_type_context_1.aiTypeContext.identify(event.aiType);
|
|
270
276
|
// Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over
|
|
271
277
|
// process.cwd(); they match today, but the payload is the authoritative signal and stays
|
|
272
278
|
// correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { branchStateHangTimeoutFor } from '../core/main-sync-timeout';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0_BLOCK, matrixL2Row } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder, renderRuleFailForAi } from '@webpieces/rules-config';\nimport { NormalizedToolInput, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { AgentHookEvent, FileOperation } from '../core/agent-event';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow, denyOutcome } from './agent-response';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\nimport { AgentPayload, AgentPayloadParser } from './agent-payload';\nimport { AgentAdapters } from './agent-adapters';\nimport { CodexSubagentSharedTreeGuard, CODEX_SUBAGENT_RULE } from './codex-subagent-guard';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst ADAPTERS = new AgentAdapters();\nconst SUBAGENT_GUARD = new CodexSubagentSharedTreeGuard();\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleBash(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n const command = event.bash === null ? '' : event.bash.command;\n if (command.trim() === '') { emitAllow(); }\n\n // READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`\n // empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives\n // as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none\n // of them. The command is STILL run through the bash guards below — this adds a verdict, it never\n // replaces one.\n for (const readPath of event.reads) {\n handleRead(event, readPath, cwd, mode);\n }\n\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only\n // field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See\n // agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(event: AgentHookEvent, filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);\n}\n\n/**\n * Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped guard\n * (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule ever sees a\n * Read, and the only way this path can deny is a stale `main`. The audit trail still records every\n * file the AI opened (see setup.ts).\n *\n * Returns normally ONLY when the event is not a Read; otherwise it ends the invocation.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleReadFastPath(event: AgentHookEvent, cwd: string, mode: HookMode): void {\n if (event.kind !== 'Read') return;\n const readPath = event.reads.length > 0 ? event.reads[0] : '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd, branchStateHangTimeoutFor(cwd));\n }\n handleRead(event, readPath, cwd, mode);\n emitAllow();\n}\n\n/**\n * The file/edit pipeline, run once per file the call touches.\n *\n * `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.\n * A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the\n * single-file behaviour it has always had.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design\nfunction handleFileTool(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n if (event.files.length === 0) { emitAllow(); }\n\n // A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every\n // Claude Code event — that harness can hand a subagent its own worktree, and does.\n const subagentBlock = SUBAGENT_GUARD.check(event, new RepoRootFinder().resolveRepoRoot(cwd));\n if (subagentBlock) {\n emitDeny(event, subagentBlock.report, CODEX_SUBAGENT_RULE, subagentBlock.fault);\n }\n\n for (const file of event.files) {\n handleOneFile(event, file, cwd, mode);\n }\n emitAllow();\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleOneFile(event: AgentHookEvent, file: FileOperation, cwd: string, mode: HookMode): void {\n const input = file.input;\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', file.toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, matrixL2Row('config-bypass (feature-branch-guard skipped)')),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root, branchStateHangTimeoutFor(cwd));\n }\n return;\n }\n\n const result = run(file.toolKind, input, cwd, mode);\n if (!result) return;\n\n logRejection(file.toolKind, input, result, cwd);\n // File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n//\n// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is\n// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.\n// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and\n// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: AgentPayload, event: AgentHookEvent, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the three managed things moved — ai-hook.sh, the settings.json registration, or its\n // managed env entry (the Bash-cwd pin that keeps a guard's verdict independent of where an earlier\n // `cd` left the shell; see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0_BLOCK),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the\n // main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = event.agentId !== '';\n emitDeny(event, shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->\n * emit`, as ONE function returning ONE value.\n *\n * `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through\n * (emitAllow). A block is a PreToolUse `permissionDecision:\"deny\"` JSON on stdout with exit 0 — see\n * agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never\n * silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on\n * a stderr+exit-2 block.\n *\n * It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by\n * HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This\n * function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and\n * `HookApp.run()` is the only entry point.\n */\n// webpieces-disable no-function-outside-class -- the module-scope hook body itself, sibling of handleBash()/handleFileTool(); the adapter is module-scope functions by design and must stay callable from a tree too broken to build a DI container\nexport function runPipeline(raw: string, mode: HookMode): HookOutcome {\n // Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which\n // kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.\n let event: AgentHookEvent | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const payload = new AgentPayloadParser().parse(raw);\n if (!payload) { emitAllow(); }\n // The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot\n // fail, and it is what the crash path needs. The full normalization below reads `tool_input`\n // and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).\n event = ADAPTERS.envelope(payload);\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(event.sessionId, event.agentId, mode));\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, event, cwd, mode);\n\n event = ADAPTERS.toEvent(payload, cwd);\n\n // Read-only tools: their own fast path, which never returns when it applies.\n handleReadFastPath(event, cwd, mode);\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, logTarget(event));\n }\n\n if (event.kind === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(event, cwd, mode);\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(event, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // The pipeline's own terminal control flow, not a failure: emitAllow/emitDeny threw the answer\n // out to here from wherever they were called. Treating it as a crash would turn every allow\n // into a deny, so this branch comes FIRST and returns the carried outcome verbatim.\n if (error instanceof HookTerminated) return error.outcome;\n return denyForCrash(error, event);\n }\n}\n\n// What the `calls/` audit line names as the call's target: the command for a shell call, else the first\n// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and\n// the decision log carry the rest, per file.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter\nfunction logTarget(event: AgentHookEvent): string {\n if (event.kind === 'Bash') return event.bash === null ? '' : event.bash.command;\n return event.files.length > 0 ? event.files[0].input.filePath : '';\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch\n * envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an\n * unexpected bug. All three DENY and surface their reason, because a hook that crashed established\n * nothing and must never be read as an allow.\n *\n * It BUILDS the deny (denyOutcome) rather than throwing it (emitDeny), because it is already inside\n * the catch that the throw would land in — see HookTerminated. Same bytes either way.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, event: AgentHookEvent | null): HookOutcome {\n if (error instanceof RuleFailError) {\n return denyOutcome(event, renderRuleFailForAi(error), 'rule-crash');\n }\n if (error instanceof InformAiError) {\n return denyOutcome(event, error.message, 'bad-config-or-stdin');\n }\n return denyOutcome(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { branchStateHangTimeoutFor } from '../core/main-sync-timeout';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0_BLOCK, matrixL2Row } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder, renderRuleFailForAi } from '@webpieces/rules-config';\nimport { NormalizedToolInput, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { AgentHookEvent, FileOperation } from '../core/agent-event';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow, denyOutcome } from './agent-response';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\nimport { AgentPayload, AgentPayloadParser } from './agent-payload';\nimport { AgentAdapters } from './agent-adapters';\nimport { CodexSubagentSharedTreeGuard, CODEX_SUBAGENT_RULE } from './codex-subagent-guard';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { aiTypeContext } from '../core/ai-type-context';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst ADAPTERS = new AgentAdapters();\nconst SUBAGENT_GUARD = new CodexSubagentSharedTreeGuard();\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleBash(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n const command = event.bash === null ? '' : event.bash.command;\n if (command.trim() === '') { emitAllow(); }\n\n // READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`\n // empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives\n // as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none\n // of them. The command is STILL run through the bash guards below — this adds a verdict, it never\n // replaces one.\n for (const readPath of event.reads) {\n handleRead(event, readPath, cwd, mode);\n }\n\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only\n // field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See\n // agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(event: AgentHookEvent, filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);\n}\n\n/**\n * Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped guard\n * (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule ever sees a\n * Read, and the only way this path can deny is a stale `main`. The audit trail still records every\n * file the AI opened (see setup.ts).\n *\n * Returns normally ONLY when the event is not a Read; otherwise it ends the invocation.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleReadFastPath(event: AgentHookEvent, cwd: string, mode: HookMode): void {\n if (event.kind !== 'Read') return;\n const readPath = event.reads.length > 0 ? event.reads[0] : '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd, branchStateHangTimeoutFor(cwd));\n }\n handleRead(event, readPath, cwd, mode);\n emitAllow();\n}\n\n/**\n * The file/edit pipeline, run once per file the call touches.\n *\n * `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.\n * A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the\n * single-file behaviour it has always had.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design\nfunction handleFileTool(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n if (event.files.length === 0) { emitAllow(); }\n\n // A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every\n // Claude Code event — that harness can hand a subagent its own worktree, and does.\n const subagentBlock = SUBAGENT_GUARD.check(event, new RepoRootFinder().resolveRepoRoot(cwd));\n if (subagentBlock) {\n emitDeny(event, subagentBlock.report, CODEX_SUBAGENT_RULE, subagentBlock.fault);\n }\n\n for (const file of event.files) {\n handleOneFile(event, file, cwd, mode);\n }\n emitAllow();\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleOneFile(event: AgentHookEvent, file: FileOperation, cwd: string, mode: HookMode): void {\n const input = file.input;\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', file.toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, matrixL2Row('config-bypass (feature-branch-guard skipped)')),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root, branchStateHangTimeoutFor(cwd));\n }\n return;\n }\n\n const result = run(file.toolKind, input, cwd, mode);\n if (!result) return;\n\n logRejection(file.toolKind, input, result, cwd);\n // File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n//\n// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is\n// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.\n// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and\n// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: AgentPayload, event: AgentHookEvent, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the managed things moved — ai-hook.sh, either harness's hook registration\n // (.claude/settings.json, .codex/hooks.json), or the Claude settings' managed env entry (the\n // Bash-cwd pin that keeps a guard's verdict independent of where an earlier `cd` left the shell;\n // see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0_BLOCK),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the\n // main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = event.agentId !== '';\n emitDeny(event, shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->\n * emit`, as ONE function returning ONE value.\n *\n * `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through\n * (emitAllow). A block is a PreToolUse `permissionDecision:\"deny\"` JSON on stdout with exit 0 — see\n * agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never\n * silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on\n * a stderr+exit-2 block.\n *\n * It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by\n * HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This\n * function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and\n * `HookApp.run()` is the only entry point.\n */\n// webpieces-disable no-function-outside-class -- the module-scope hook body itself, sibling of handleBash()/handleFileTool(); the adapter is module-scope functions by design and must stay callable from a tree too broken to build a DI container\nexport function runPipeline(raw: string, mode: HookMode): HookOutcome {\n // Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which\n // kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.\n let event: AgentHookEvent | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const payload = new AgentPayloadParser().parse(raw);\n if (!payload) { emitAllow(); }\n // The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot\n // fail, and it is what the crash path needs. The full normalization below reads `tool_input`\n // and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).\n event = ADAPTERS.envelope(payload);\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(event.sessionId, event.agentId, mode));\n // Same moment, same reason: every JS-side stream stamps `ai=`, and the fault-S line written\n // inside enforceCommittedShim() is one of them. See AiTypeContext for why the harness is a\n // process-wide holder rather than a parameter on four writers.\n aiTypeContext.identify(event.aiType);\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, event, cwd, mode);\n\n event = ADAPTERS.toEvent(payload, cwd);\n\n // Read-only tools: their own fast path, which never returns when it applies.\n handleReadFastPath(event, cwd, mode);\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, logTarget(event));\n }\n\n if (event.kind === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(event, cwd, mode);\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(event, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // The pipeline's own terminal control flow, not a failure: emitAllow/emitDeny threw the answer\n // out to here from wherever they were called. Treating it as a crash would turn every allow\n // into a deny, so this branch comes FIRST and returns the carried outcome verbatim.\n if (error instanceof HookTerminated) return error.outcome;\n return denyForCrash(error, event);\n }\n}\n\n// What the `calls/` audit line names as the call's target: the command for a shell call, else the first\n// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and\n// the decision log carry the rest, per file.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter\nfunction logTarget(event: AgentHookEvent): string {\n if (event.kind === 'Bash') return event.bash === null ? '' : event.bash.command;\n return event.files.length > 0 ? event.files[0].input.filePath : '';\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch\n * envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an\n * unexpected bug. All three DENY and surface their reason, because a hook that crashed established\n * nothing and must never be read as an allow.\n *\n * It BUILDS the deny (denyOutcome) rather than throwing it (emitDeny), because it is already inside\n * the catch that the throw would land in — see HookTerminated. Same bytes either way.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, event: AgentHookEvent | null): HookOutcome {\n if (error instanceof RuleFailError) {\n return denyOutcome(event, renderRuleFailForAi(error), 'rule-crash');\n }\n if (error instanceof InformAiError) {\n return denyOutcome(event, error.message, 'bad-config-or-stdin');\n }\n return denyOutcome(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');\n}\n"]}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GUARD-PRESENCE ATTESTATION — "did the guards actually RUN in this Codex session?"
|
|
3
|
+
*
|
|
4
|
+
* ─── The hole this closes ─────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Codex prompts before running a hook it has not trusted, and the prompt's third option is
|
|
6
|
+
* `Continue without trusting (hooks won't run)`. One keystroke, no later warning, and the session is
|
|
7
|
+
* fully unguarded for its whole life. Nothing at INSTALL time can see that choice — it is made
|
|
8
|
+
* afterwards, in another process — so `codex-trust.ts` reading `~/.codex/config.toml` is necessary and
|
|
9
|
+
* not sufficient.
|
|
10
|
+
*
|
|
11
|
+
* The same check catches a second, quieter failure with no relation to trust: a matcher that names a
|
|
12
|
+
* tool Codex does not emit. That was the live state of every `.codex/hooks.json` a desktop sync had
|
|
13
|
+
* written — `Write|Edit|MultiEdit` against a harness whose file tool is `apply_patch` — and it produced
|
|
14
|
+
* exactly the same symptom, which is no symptom at all.
|
|
15
|
+
*
|
|
16
|
+
* ─── Why the L0 shim log is the evidence ──────────────────────────────────────────────────────────
|
|
17
|
+
* The L0 shim writes one row per tool call, on EVERY path including the healthy one, before anything
|
|
18
|
+
* else can fail. So a session that produced ZERO rows did not have a guard run — that is the whole
|
|
19
|
+
* inference, and it holds no matter WHY (untrusted, wrong matcher, deleted file, unresolvable path).
|
|
20
|
+
*
|
|
21
|
+
* It is deliberately a count of rows and not a check of any row's content: what is being attested is
|
|
22
|
+
* that the hook EXECUTED, not what it decided.
|
|
23
|
+
*
|
|
24
|
+
* ─── Scope, stated honestly ───────────────────────────────────────────────────────────────────────
|
|
25
|
+
* This is a REUSABLE CHECK, not yet a gate. `check()` returns a verdict and a message; wiring it into
|
|
26
|
+
* `wp-build` and the PR gate's build stage is a follow-up, because the build path lives in a different
|
|
27
|
+
* published package (`@webpieces/pr-gate`) and a half-wired gate — one that detects but never blocks —
|
|
28
|
+
* would be worse than none: it would read as coverage nobody has.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Is THIS process running inside a Codex session?
|
|
32
|
+
*
|
|
33
|
+
* MEASURED (codex-cli 0.151.0), and the negative half matters as much as the positive: there is NO
|
|
34
|
+
* `CODEX_SESSION_ID`. Reaching for one is the obvious thing to do and it does not exist, so the
|
|
35
|
+
* fingerprints are the three that DO: `CODEX_MANAGED_BY_NPM`, `CODEX_MANAGED_PACKAGE_ROOT`, and a
|
|
36
|
+
* `/.codex/tmp/arg0/` entry on `PATH`.
|
|
37
|
+
*
|
|
38
|
+
* Any one of them is enough. They come from different install shapes, and requiring all three would
|
|
39
|
+
* silently answer "not Codex" — which for a check that BLOCKS on absence of evidence is the dangerous
|
|
40
|
+
* direction: it would turn an unguarded session into an unchecked one.
|
|
41
|
+
*/
|
|
42
|
+
export declare class CodexSessionDetector {
|
|
43
|
+
/** The env keys that identify a Codex-managed process. */
|
|
44
|
+
static readonly ENV_KEYS: readonly string[];
|
|
45
|
+
/** The PATH segment Codex injects for its arg0 shims. */
|
|
46
|
+
static readonly PATH_MARKER = "/.codex/tmp/arg0/";
|
|
47
|
+
isCodexSession(env?: NodeJS.ProcessEnv): boolean;
|
|
48
|
+
}
|
|
49
|
+
/** The answer, with the sentence a human or an agent reads. Data-only → a class, per CLAUDE.md. */
|
|
50
|
+
export declare class GuardPresenceVerdict {
|
|
51
|
+
/** True ⇒ the caller may proceed. False ⇒ BLOCK: a Codex session ran with no guard rows. */
|
|
52
|
+
readonly ok: boolean;
|
|
53
|
+
/** Why, in one line — always populated, including on the green paths. */
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
/** How many L0 shim rows this tree has for the session under attestation. */
|
|
56
|
+
readonly rows: number;
|
|
57
|
+
constructor(
|
|
58
|
+
/** True ⇒ the caller may proceed. False ⇒ BLOCK: a Codex session ran with no guard rows. */
|
|
59
|
+
ok: boolean,
|
|
60
|
+
/** Why, in one line — always populated, including on the green paths. */
|
|
61
|
+
reason: string,
|
|
62
|
+
/** How many L0 shim rows this tree has for the session under attestation. */
|
|
63
|
+
rows: number);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The check itself: in a Codex session, REFUSE when the tree has no L0 shim rows at all.
|
|
67
|
+
*
|
|
68
|
+
* Outside a Codex session it is a no-op that says so, which is what makes it safe to call
|
|
69
|
+
* unconditionally from a shared build path.
|
|
70
|
+
*/
|
|
71
|
+
export declare class CodexGuardPresence {
|
|
72
|
+
private readonly detector;
|
|
73
|
+
/**
|
|
74
|
+
* `root` is the tree whose `.webpieces` logs are the evidence — the same root every other webpieces
|
|
75
|
+
* writer resolves, so a worktree is attested by its own rows rather than the primary clone's.
|
|
76
|
+
*/
|
|
77
|
+
check(root: string, env?: NodeJS.ProcessEnv): GuardPresenceVerdict;
|
|
78
|
+
/**
|
|
79
|
+
* The refusal text. It names the two causes that produce this identical symptom, because they have
|
|
80
|
+
* DIFFERENT cures and an agent handed only one of them will run it, see nothing change, and conclude
|
|
81
|
+
* the check is broken.
|
|
82
|
+
*/
|
|
83
|
+
private refusal;
|
|
84
|
+
/** How many `.log` lines the L0 shim stream holds for this tree. Never throws; 0 on any failure. */
|
|
85
|
+
private shimRowCount;
|
|
86
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CodexGuardPresence = exports.GuardPresenceVerdict = exports.CodexSessionDetector = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
|
+
const log_streams_1 = require("../core/log-streams");
|
|
9
|
+
const to_error_1 = require("../core/to-error");
|
|
10
|
+
/**
|
|
11
|
+
* GUARD-PRESENCE ATTESTATION — "did the guards actually RUN in this Codex session?"
|
|
12
|
+
*
|
|
13
|
+
* ─── The hole this closes ─────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* Codex prompts before running a hook it has not trusted, and the prompt's third option is
|
|
15
|
+
* `Continue without trusting (hooks won't run)`. One keystroke, no later warning, and the session is
|
|
16
|
+
* fully unguarded for its whole life. Nothing at INSTALL time can see that choice — it is made
|
|
17
|
+
* afterwards, in another process — so `codex-trust.ts` reading `~/.codex/config.toml` is necessary and
|
|
18
|
+
* not sufficient.
|
|
19
|
+
*
|
|
20
|
+
* The same check catches a second, quieter failure with no relation to trust: a matcher that names a
|
|
21
|
+
* tool Codex does not emit. That was the live state of every `.codex/hooks.json` a desktop sync had
|
|
22
|
+
* written — `Write|Edit|MultiEdit` against a harness whose file tool is `apply_patch` — and it produced
|
|
23
|
+
* exactly the same symptom, which is no symptom at all.
|
|
24
|
+
*
|
|
25
|
+
* ─── Why the L0 shim log is the evidence ──────────────────────────────────────────────────────────
|
|
26
|
+
* The L0 shim writes one row per tool call, on EVERY path including the healthy one, before anything
|
|
27
|
+
* else can fail. So a session that produced ZERO rows did not have a guard run — that is the whole
|
|
28
|
+
* inference, and it holds no matter WHY (untrusted, wrong matcher, deleted file, unresolvable path).
|
|
29
|
+
*
|
|
30
|
+
* It is deliberately a count of rows and not a check of any row's content: what is being attested is
|
|
31
|
+
* that the hook EXECUTED, not what it decided.
|
|
32
|
+
*
|
|
33
|
+
* ─── Scope, stated honestly ───────────────────────────────────────────────────────────────────────
|
|
34
|
+
* This is a REUSABLE CHECK, not yet a gate. `check()` returns a verdict and a message; wiring it into
|
|
35
|
+
* `wp-build` and the PR gate's build stage is a follow-up, because the build path lives in a different
|
|
36
|
+
* published package (`@webpieces/pr-gate`) and a half-wired gate — one that detects but never blocks —
|
|
37
|
+
* would be worse than none: it would read as coverage nobody has.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Is THIS process running inside a Codex session?
|
|
41
|
+
*
|
|
42
|
+
* MEASURED (codex-cli 0.151.0), and the negative half matters as much as the positive: there is NO
|
|
43
|
+
* `CODEX_SESSION_ID`. Reaching for one is the obvious thing to do and it does not exist, so the
|
|
44
|
+
* fingerprints are the three that DO: `CODEX_MANAGED_BY_NPM`, `CODEX_MANAGED_PACKAGE_ROOT`, and a
|
|
45
|
+
* `/.codex/tmp/arg0/` entry on `PATH`.
|
|
46
|
+
*
|
|
47
|
+
* Any one of them is enough. They come from different install shapes, and requiring all three would
|
|
48
|
+
* silently answer "not Codex" — which for a check that BLOCKS on absence of evidence is the dangerous
|
|
49
|
+
* direction: it would turn an unguarded session into an unchecked one.
|
|
50
|
+
*/
|
|
51
|
+
class CodexSessionDetector {
|
|
52
|
+
/** The env keys that identify a Codex-managed process. */
|
|
53
|
+
static ENV_KEYS = ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_PACKAGE_ROOT'];
|
|
54
|
+
/** The PATH segment Codex injects for its arg0 shims. */
|
|
55
|
+
static PATH_MARKER = '/.codex/tmp/arg0/';
|
|
56
|
+
isCodexSession(env = process.env) {
|
|
57
|
+
for (const key of CodexSessionDetector.ENV_KEYS) {
|
|
58
|
+
const value = env[key];
|
|
59
|
+
if (value !== undefined && value !== '')
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const search = env['PATH'] ?? '';
|
|
63
|
+
return search.includes(CodexSessionDetector.PATH_MARKER);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.CodexSessionDetector = CodexSessionDetector;
|
|
67
|
+
/** The answer, with the sentence a human or an agent reads. Data-only → a class, per CLAUDE.md. */
|
|
68
|
+
class GuardPresenceVerdict {
|
|
69
|
+
ok;
|
|
70
|
+
reason;
|
|
71
|
+
rows;
|
|
72
|
+
constructor(
|
|
73
|
+
/** True ⇒ the caller may proceed. False ⇒ BLOCK: a Codex session ran with no guard rows. */
|
|
74
|
+
ok,
|
|
75
|
+
/** Why, in one line — always populated, including on the green paths. */
|
|
76
|
+
reason,
|
|
77
|
+
/** How many L0 shim rows this tree has for the session under attestation. */
|
|
78
|
+
rows) {
|
|
79
|
+
this.ok = ok;
|
|
80
|
+
this.reason = reason;
|
|
81
|
+
this.rows = rows;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.GuardPresenceVerdict = GuardPresenceVerdict;
|
|
85
|
+
/**
|
|
86
|
+
* The check itself: in a Codex session, REFUSE when the tree has no L0 shim rows at all.
|
|
87
|
+
*
|
|
88
|
+
* Outside a Codex session it is a no-op that says so, which is what makes it safe to call
|
|
89
|
+
* unconditionally from a shared build path.
|
|
90
|
+
*/
|
|
91
|
+
class CodexGuardPresence {
|
|
92
|
+
detector = new CodexSessionDetector();
|
|
93
|
+
/**
|
|
94
|
+
* `root` is the tree whose `.webpieces` logs are the evidence — the same root every other webpieces
|
|
95
|
+
* writer resolves, so a worktree is attested by its own rows rather than the primary clone's.
|
|
96
|
+
*/
|
|
97
|
+
check(root, env = process.env) {
|
|
98
|
+
if (!this.detector.isCodexSession(env)) {
|
|
99
|
+
return new GuardPresenceVerdict(true, 'not a Codex session — guard presence is not attested here', 0);
|
|
100
|
+
}
|
|
101
|
+
const rows = this.shimRowCount(root);
|
|
102
|
+
if (rows > 0) {
|
|
103
|
+
return new GuardPresenceVerdict(true, `Codex session with ${String(rows)} L0 guard row(s) — the guards ran`, rows);
|
|
104
|
+
}
|
|
105
|
+
return new GuardPresenceVerdict(false, this.refusal(root), 0);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The refusal text. It names the two causes that produce this identical symptom, because they have
|
|
109
|
+
* DIFFERENT cures and an agent handed only one of them will run it, see nothing change, and conclude
|
|
110
|
+
* the check is broken.
|
|
111
|
+
*/
|
|
112
|
+
refusal(root) {
|
|
113
|
+
return [
|
|
114
|
+
'❌ webpieces: this is a Codex session and NOT ONE guard has run in this tree.',
|
|
115
|
+
'',
|
|
116
|
+
` no rows in ${path.join(rules_config_1.dotWebpieces.logs(root), log_streams_1.L0_SHIM_STREAM)}`,
|
|
117
|
+
' → the L0 shim writes one row per tool call on EVERY path, including the healthy one, so',
|
|
118
|
+
' zero rows means the PreToolUse hook never executed. Every tool call so far was unguarded.',
|
|
119
|
+
'',
|
|
120
|
+
' Cause 1 (most likely): you answered "Continue without trusting (hooks won\'t run)" at Codex\'s',
|
|
121
|
+
' hook prompt. Fix: restart `codex` in this repo and choose "Trust all".',
|
|
122
|
+
' Cause 2: .codex/hooks.json registers a matcher Codex never emits, or a shim path that does',
|
|
123
|
+
' not resolve. Fix: run EXACTLY: \'pnpm exec wp-install-ai-hooks --target=project\'',
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
/** How many `.log` lines the L0 shim stream holds for this tree. Never throws; 0 on any failure. */
|
|
127
|
+
shimRowCount(root) {
|
|
128
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
129
|
+
try {
|
|
130
|
+
const streamDir = path.join(rules_config_1.dotWebpieces.logs(root), log_streams_1.L0_SHIM_STREAM);
|
|
131
|
+
if (!fs.existsSync(streamDir))
|
|
132
|
+
return 0;
|
|
133
|
+
let rows = 0;
|
|
134
|
+
for (const name of fs.readdirSync(streamDir)) {
|
|
135
|
+
if (!name.endsWith('.log'))
|
|
136
|
+
continue;
|
|
137
|
+
const body = fs.readFileSync(path.join(streamDir, name), 'utf8');
|
|
138
|
+
rows += body.split('\n').filter((line) => line.trim() !== '').length;
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const error = (0, to_error_1.toError)(err);
|
|
144
|
+
void error; // an unreadable log dir is ZERO rows — the direction that refuses rather than waves through
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
exports.CodexGuardPresence = CodexGuardPresence;
|
|
150
|
+
//# sourceMappingURL=codex-guard-presence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex-guard-presence.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/codex-guard-presence.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces } from '@webpieces/rules-config';\n\nimport { L0_SHIM_STREAM } from '../core/log-streams';\nimport { toError } from '../core/to-error';\n\n/**\n * GUARD-PRESENCE ATTESTATION — \"did the guards actually RUN in this Codex session?\"\n *\n * ─── The hole this closes ─────────────────────────────────────────────────────────────────────────\n * Codex prompts before running a hook it has not trusted, and the prompt's third option is\n * `Continue without trusting (hooks won't run)`. One keystroke, no later warning, and the session is\n * fully unguarded for its whole life. Nothing at INSTALL time can see that choice — it is made\n * afterwards, in another process — so `codex-trust.ts` reading `~/.codex/config.toml` is necessary and\n * not sufficient.\n *\n * The same check catches a second, quieter failure with no relation to trust: a matcher that names a\n * tool Codex does not emit. That was the live state of every `.codex/hooks.json` a desktop sync had\n * written — `Write|Edit|MultiEdit` against a harness whose file tool is `apply_patch` — and it produced\n * exactly the same symptom, which is no symptom at all.\n *\n * ─── Why the L0 shim log is the evidence ──────────────────────────────────────────────────────────\n * The L0 shim writes one row per tool call, on EVERY path including the healthy one, before anything\n * else can fail. So a session that produced ZERO rows did not have a guard run — that is the whole\n * inference, and it holds no matter WHY (untrusted, wrong matcher, deleted file, unresolvable path).\n *\n * It is deliberately a count of rows and not a check of any row's content: what is being attested is\n * that the hook EXECUTED, not what it decided.\n *\n * ─── Scope, stated honestly ───────────────────────────────────────────────────────────────────────\n * This is a REUSABLE CHECK, not yet a gate. `check()` returns a verdict and a message; wiring it into\n * `wp-build` and the PR gate's build stage is a follow-up, because the build path lives in a different\n * published package (`@webpieces/pr-gate`) and a half-wired gate — one that detects but never blocks —\n * would be worse than none: it would read as coverage nobody has.\n */\n\n/**\n * Is THIS process running inside a Codex session?\n *\n * MEASURED (codex-cli 0.151.0), and the negative half matters as much as the positive: there is NO\n * `CODEX_SESSION_ID`. Reaching for one is the obvious thing to do and it does not exist, so the\n * fingerprints are the three that DO: `CODEX_MANAGED_BY_NPM`, `CODEX_MANAGED_PACKAGE_ROOT`, and a\n * `/.codex/tmp/arg0/` entry on `PATH`.\n *\n * Any one of them is enough. They come from different install shapes, and requiring all three would\n * silently answer \"not Codex\" — which for a check that BLOCKS on absence of evidence is the dangerous\n * direction: it would turn an unguarded session into an unchecked one.\n */\nexport class CodexSessionDetector {\n /** The env keys that identify a Codex-managed process. */\n static readonly ENV_KEYS: readonly string[] = ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_PACKAGE_ROOT'];\n\n /** The PATH segment Codex injects for its arg0 shims. */\n static readonly PATH_MARKER = '/.codex/tmp/arg0/';\n\n isCodexSession(env: NodeJS.ProcessEnv = process.env): boolean {\n for (const key of CodexSessionDetector.ENV_KEYS) {\n const value = env[key];\n if (value !== undefined && value !== '') return true;\n }\n const search = env['PATH'] ?? '';\n return search.includes(CodexSessionDetector.PATH_MARKER);\n }\n}\n\n/** The answer, with the sentence a human or an agent reads. Data-only → a class, per CLAUDE.md. */\nexport class GuardPresenceVerdict {\n constructor(\n /** True ⇒ the caller may proceed. False ⇒ BLOCK: a Codex session ran with no guard rows. */\n readonly ok: boolean,\n /** Why, in one line — always populated, including on the green paths. */\n readonly reason: string,\n /** How many L0 shim rows this tree has for the session under attestation. */\n readonly rows: number,\n ) {}\n}\n\n/**\n * The check itself: in a Codex session, REFUSE when the tree has no L0 shim rows at all.\n *\n * Outside a Codex session it is a no-op that says so, which is what makes it safe to call\n * unconditionally from a shared build path.\n */\nexport class CodexGuardPresence {\n private readonly detector = new CodexSessionDetector();\n\n /**\n * `root` is the tree whose `.webpieces` logs are the evidence — the same root every other webpieces\n * writer resolves, so a worktree is attested by its own rows rather than the primary clone's.\n */\n check(root: string, env: NodeJS.ProcessEnv = process.env): GuardPresenceVerdict {\n if (!this.detector.isCodexSession(env)) {\n return new GuardPresenceVerdict(true, 'not a Codex session — guard presence is not attested here', 0);\n }\n const rows = this.shimRowCount(root);\n if (rows > 0) {\n return new GuardPresenceVerdict(true, `Codex session with ${String(rows)} L0 guard row(s) — the guards ran`, rows);\n }\n return new GuardPresenceVerdict(false, this.refusal(root), 0);\n }\n\n /**\n * The refusal text. It names the two causes that produce this identical symptom, because they have\n * DIFFERENT cures and an agent handed only one of them will run it, see nothing change, and conclude\n * the check is broken.\n */\n private refusal(root: string): string {\n return [\n '❌ webpieces: this is a Codex session and NOT ONE guard has run in this tree.',\n '',\n ` no rows in ${path.join(dotWebpieces.logs(root), L0_SHIM_STREAM)}`,\n ' → the L0 shim writes one row per tool call on EVERY path, including the healthy one, so',\n ' zero rows means the PreToolUse hook never executed. Every tool call so far was unguarded.',\n '',\n ' Cause 1 (most likely): you answered \"Continue without trusting (hooks won\\'t run)\" at Codex\\'s',\n ' hook prompt. Fix: restart `codex` in this repo and choose \"Trust all\".',\n ' Cause 2: .codex/hooks.json registers a matcher Codex never emits, or a shim path that does',\n ' not resolve. Fix: run EXACTLY: \\'pnpm exec wp-install-ai-hooks --target=project\\'',\n ].join('\\n');\n }\n\n /** How many `.log` lines the L0 shim stream holds for this tree. Never throws; 0 on any failure. */\n private shimRowCount(root: string): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const streamDir = path.join(dotWebpieces.logs(root), L0_SHIM_STREAM);\n if (!fs.existsSync(streamDir)) return 0;\n let rows = 0;\n for (const name of fs.readdirSync(streamDir)) {\n if (!name.endsWith('.log')) continue;\n const body = fs.readFileSync(path.join(streamDir, name), 'utf8');\n rows += body.split('\\n').filter((line: string): boolean => line.trim() !== '').length;\n }\n return rows;\n } catch (err: unknown) {\n const error = toError(err);\n void error; // an unreadable log dir is ZERO rows — the direction that refuses rather than waves through\n return 0;\n }\n }\n}\n"]}
|