@webpieces/ai-hook-rules 0.4.703 → 0.4.705

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.
Files changed (64) hide show
  1. package/package.json +4 -2
  2. package/src/adapters/agent-adapters.d.ts +13 -0
  3. package/src/adapters/agent-adapters.js +23 -0
  4. package/src/adapters/agent-adapters.js.map +1 -0
  5. package/src/adapters/agent-payload.d.ts +48 -0
  6. package/src/adapters/agent-payload.js +30 -0
  7. package/src/adapters/agent-payload.js.map +1 -0
  8. package/src/adapters/agent-response.d.ts +6 -0
  9. package/src/adapters/{claude-code-response.js → agent-response.js} +56 -22
  10. package/src/adapters/agent-response.js.map +1 -0
  11. package/src/adapters/claude-code-adapter.d.ts +26 -0
  12. package/src/adapters/claude-code-adapter.js +69 -0
  13. package/src/adapters/claude-code-adapter.js.map +1 -0
  14. package/src/adapters/codex-adapter.d.ts +19 -0
  15. package/src/adapters/codex-adapter.js +48 -0
  16. package/src/adapters/codex-adapter.js.map +1 -0
  17. package/src/adapters/codex-subagent-guard.d.ts +30 -0
  18. package/src/adapters/codex-subagent-guard.js +58 -0
  19. package/src/adapters/codex-subagent-guard.js.map +1 -0
  20. package/src/adapters/detect-ai.d.ts +36 -0
  21. package/src/adapters/detect-ai.js +47 -0
  22. package/src/adapters/detect-ai.js.map +1 -0
  23. package/src/adapters/guards-hook.d.ts +1 -0
  24. package/src/adapters/guards-hook.js +21 -6
  25. package/src/adapters/guards-hook.js.map +1 -1
  26. package/src/adapters/hook-app-fixtures.d.ts +68 -0
  27. package/src/adapters/hook-app-fixtures.js +175 -0
  28. package/src/adapters/hook-app-fixtures.js.map +1 -0
  29. package/src/adapters/hook-app.d.ts +61 -0
  30. package/src/adapters/hook-app.js +110 -0
  31. package/src/adapters/hook-app.js.map +1 -0
  32. package/src/adapters/hook-core.d.ts +15 -6
  33. package/src/adapters/hook-core.js +157 -139
  34. package/src/adapters/hook-core.js.map +1 -1
  35. package/src/adapters/hook-outcome.d.ts +46 -0
  36. package/src/adapters/hook-outcome.js +60 -0
  37. package/src/adapters/hook-outcome.js.map +1 -0
  38. package/src/adapters/hook-ports.d.ts +57 -0
  39. package/src/adapters/hook-ports.js +90 -0
  40. package/src/adapters/hook-ports.js.map +1 -0
  41. package/src/adapters/rules-hook.d.ts +1 -0
  42. package/src/adapters/rules-hook.js +19 -5
  43. package/src/adapters/rules-hook.js.map +1 -1
  44. package/src/core/agent-event.d.ts +65 -0
  45. package/src/core/agent-event.js +59 -0
  46. package/src/core/agent-event.js.map +1 -0
  47. package/src/core/apply-patch-parse.d.ts +36 -0
  48. package/src/core/apply-patch-parse.js +154 -0
  49. package/src/core/apply-patch-parse.js.map +1 -0
  50. package/src/core/delete-scoped-rules.d.ts +8 -0
  51. package/src/core/delete-scoped-rules.js +31 -0
  52. package/src/core/delete-scoped-rules.js.map +1 -0
  53. package/src/core/runner.js +2 -1
  54. package/src/core/runner.js.map +1 -1
  55. package/src/core/shell-read-parity.d.ts +22 -0
  56. package/src/core/shell-read-parity.js +145 -0
  57. package/src/core/shell-read-parity.js.map +1 -0
  58. package/src/core/types.d.ts +1 -1
  59. package/src/core/types.js.map +1 -1
  60. package/src/index.d.ts +2 -0
  61. package/src/index.js +11 -1
  62. package/src/index.js.map +1 -1
  63. package/src/adapters/claude-code-response.d.ts +0 -3
  64. package/src/adapters/claude-code-response.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-subagent-guard.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/codex-subagent-guard.ts"],"names":[],"mappings":";;;;AAAA,mDAA6B;AAE7B,0DAAqF;AAGrF,yCAA8C;AAEjC,QAAA,mBAAmB,GAAG,wCAAwC,CAAC;AAE5E;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,4BAA4B;IACrC;;;OAGG;IACH,KAAK,CAAC,KAAqB,EAAE,IAAY;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC;QAC1C,IAAI,KAAK,CAAC,OAAO,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC;QACtC,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAgB,EAAW,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;QACxG,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACrC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAgB,EAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3G,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,gBAAgB,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAI,4BAAa,CAC3B,2BAAmB,EACnB,6EAA6E,OAAO,MAAM;YAC1F,8FAA8F;YAC9F,8FAA8F;YAC9F,yDAAyD,EACzD,SAAS,EACT,SAAS,EACT;YACI,IAAI,qBAAM,CAAC,wGAAwG,QAAQ,cAAc,EAAE,IAAI,CAAC;YAChJ,IAAI,qBAAM,CAAC,wGAAwG,CAAC;SACvH,CACJ,CAAC;QACF,OAAO,IAAI,qBAAa,CAAC,IAAA,kCAAmB,EAAC,KAAK,CAAC,CAAC,CAAC;IACzD,CAAC;IAEO,QAAQ,CAAC,QAAgB,EAAE,IAAY;QAC3C,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QACrE,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAC1D,CAAC;CACJ;AAhCD,oEAgCC","sourcesContent":["import * as path from 'path';\n\nimport { Option, RuleFailError, renderRuleFailForAi } from '@webpieces/rules-config';\n\nimport { AgentHookEvent, FileOperation } from '../core/agent-event';\nimport { BlockedResult } from '../core/types';\n\nexport const CODEX_SUBAGENT_RULE = 'codex-subagent-no-write-in-shared-tree';\n\n/**\n * A Codex SUBAGENT may not write into the tree it shares with its coordinator.\n *\n * This exists because of a MEASURED structural gap, not a style preference. Claude Code can hand a\n * subagent its own git worktree (`isolation: \"worktree\"`), so two agents editing at once are editing\n * two different checkouts. Codex cannot: `spawn_agent`'s schema is\n * `{fork_turns?, message, model?, reasoning_effort?, task_name}` — there is no cwd, workdir or\n * worktree parameter — and cwd resets to the repo root before every command, so a subagent cannot even\n * put itself somewhere else. Every Codex subagent therefore writes into the coordinator's checkout,\n * and concurrent subagents write into each other's.\n *\n * Reviewers are unaffected: they only read, and a read arrives as `Bash`.\n *\n * NOT a configurable rule yet, and that is deliberate. A rule registered with the engine must have an\n * entry in webpieces.config.json, and the validator that would accept a new key is a RELEASE behind\n * the source that defines it — adding both at once rejects the key as unknown and blocks every tool\n * call in the repo. So the guard ships here, gated on `aiType === 'codex'`, and becomes a config-keyed\n * rule in the follow-up PR that lands after the publish.\n */\nexport class CodexSubagentSharedTreeGuard {\n /**\n * Returns a block when a Codex SUBAGENT's patch targets a file inside `root`, else null.\n * Claude Code events return null unconditionally — the harness has real isolation.\n */\n check(event: AgentHookEvent, root: string): BlockedResult | null {\n if (event.aiType !== 'codex') return null;\n if (event.agentId === '') return null;\n const inside = event.files.filter((f: FileOperation): boolean => this.isInside(f.input.filePath, root));\n if (inside.length === 0) return null;\n const targets = inside.map((f: FileOperation): string => path.relative(root, f.input.filePath)).join(', ');\n const worktree = path.join(path.dirname(root), 'wt-<task-name>');\n const error = new RuleFailError(\n CODEX_SUBAGENT_RULE,\n `A Codex subagent is writing into the tree it shares with its coordinator: ${targets}\\n\\n` +\n `Codex cannot spawn a subagent into its own checkout — spawn_agent takes no cwd or worktree, ` +\n `and cwd resets to the repo root before every command — so this edit lands in the same files ` +\n `the coordinator and every sibling subagent are editing.`,\n undefined,\n undefined,\n [\n new Option(`Give this agent its own checkout and address every file by ABSOLUTE path inside it: git worktree add ${worktree} -b <branch>`, true),\n new Option('Hand the edit back to the coordinator and have this agent report what to change instead of changing it'),\n ],\n );\n return new BlockedResult(renderRuleFailForAi(error));\n }\n\n private isInside(filePath: string, root: string): boolean {\n const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;\n return path.resolve(filePath).startsWith(rootWithSep);\n }\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import { AiType } from '../core/agent-event';
2
+ /**
3
+ * THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;
4
+ * Claude Code's has no such key. Everything else in the two envelopes is the same key names
5
+ * (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is
6
+ * exactly why one positive key is the whole test rather than a shape heuristic.
7
+ *
8
+ * Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must
9
+ * answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes
10
+ * text) and this binary (which has the parsed object). That is the same pattern
11
+ * ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
12
+ * asserts the two agree over a corpus the same way.
13
+ *
14
+ * The sh half is an APPROXIMATION and says so out loud: it matches the six bytes `"turn_id":` in the
15
+ * raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling
16
+ * inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser
17
+ * cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
18
+ * the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
19
+ *
20
+ * NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed
21
+ * `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the
22
+ * renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.
23
+ * The constant ships here first; the shim consumes it a release later.
24
+ */
25
+ export declare const AI_TYPE_TOKEN_SH = "\"turn_id\":";
26
+ /**
27
+ * Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
28
+ * are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
29
+ * instead of translating between two vocabularies (translation is where twins drift).
30
+ */
31
+ export declare const AI_TYPE_SH = "case \"$PAYLOAD\" in *'\"turn_id\":'*) AI=codex ;; *) AI=claude-code ;; esac";
32
+ /**
33
+ * JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
34
+ * the top-level envelope?
35
+ */
36
+ export declare function detectAiType(payload: unknown): AiType;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AI_TYPE_SH = exports.AI_TYPE_TOKEN_SH = void 0;
4
+ exports.detectAiType = detectAiType;
5
+ /**
6
+ * THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;
7
+ * Claude Code's has no such key. Everything else in the two envelopes is the same key names
8
+ * (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is
9
+ * exactly why one positive key is the whole test rather than a shape heuristic.
10
+ *
11
+ * Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must
12
+ * answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes
13
+ * text) and this binary (which has the parsed object). That is the same pattern
14
+ * ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
15
+ * asserts the two agree over a corpus the same way.
16
+ *
17
+ * The sh half is an APPROXIMATION and says so out loud: it matches the six bytes `"turn_id":` in the
18
+ * raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling
19
+ * inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser
20
+ * cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
21
+ * the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
22
+ *
23
+ * NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed
24
+ * `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the
25
+ * renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.
26
+ * The constant ships here first; the shim consumes it a release later.
27
+ */
28
+ exports.AI_TYPE_TOKEN_SH = '"turn_id":';
29
+ /**
30
+ * Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
31
+ * are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
32
+ * instead of translating between two vocabularies (translation is where twins drift).
33
+ */
34
+ exports.AI_TYPE_SH = `case "$PAYLOAD" in *'${exports.AI_TYPE_TOKEN_SH}'*) AI=codex ;; *) AI=claude-code ;; esac`;
35
+ /**
36
+ * JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
37
+ * the top-level envelope?
38
+ */
39
+ // 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
40
+ // 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()
41
+ function detectAiType(payload) {
42
+ if (payload === null || typeof payload !== 'object')
43
+ return 'claude-code';
44
+ // webpieces-disable no-any-unknown -- narrowing the same unparsed JSON; the index signature is the widest true statement about it
45
+ return 'turn_id' in payload ? 'codex' : 'claude-code';
46
+ }
47
+ //# sourceMappingURL=detect-ai.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;AAwCA,oCAIC;AA1CD;;;;;;;;;;;;;;;;;;;;;;GAsBG;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 * NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed\n * `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the\n * renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.\n * The constant ships here first; the shim consumes it a release later.\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"]}
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
+ import 'reflect-metadata';
2
3
  export declare function main(): Promise<void>;
@@ -2,13 +2,28 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.main = main;
5
- // Claude Code PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash).
6
- // File-edit payloads pass through untouched — code-style validation is the separate rules hook.
7
- const hook_core_1 = require("./hook-core");
8
- function main() {
9
- return (0, hook_core_1.runMain)('guards');
5
+ // Claude Code / Codex PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash|Write|Edit|
6
+ // MultiEdit|Read). Code-style validation is the separate rules hook.
7
+ //
8
+ // COMPOSITION ROOT, and deliberately nothing else: build the container, get the app, run it. Every
9
+ // decision this binary makes lives behind HookApp; the only thing that distinguishes it from
10
+ // rules-hook.ts is the one HookArgs it constructs.
11
+ require("reflect-metadata");
12
+ const inversify_1 = require("inversify");
13
+ const hook_app_1 = require("./hook-app");
14
+ const hook_outcome_1 = require("./hook-outcome");
15
+ // webpieces-disable no-function-outside-class -- this IS the bin's process entry point, named in package.json `exports` as ./claude-code-guards / ./claude-code-rules; a class here would be a namespace around one call and could not be the module's callable entry.
16
+ async function main() {
17
+ const container = new inversify_1.Container({ autobind: true });
18
+ const app = container.get(hook_app_1.HookApp);
19
+ await app.run(new hook_outcome_1.HookArgs('guards'));
10
20
  }
21
+ // `.catch` and not a bare `void main()`: a container that cannot be built throws BEFORE any HookApp
22
+ // exists, and an unhandled rejection exits non-zero — which PreToolUse reads as a non-blocking error
23
+ // and lets the tool call through. See HookBootFailure. `main` is async so a synchronous throw inside it
24
+ // arrives here as a rejection too.
11
25
  if (require.main === module) {
12
- void main();
26
+ // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; HookBootFailure narrows it through toError(), which is the one place that job belongs
27
+ void main().catch((err) => { new hook_app_1.HookBootFailure().report(err); });
13
28
  }
14
29
  //# sourceMappingURL=guards-hook.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":";;;AAKA,oBAEC;AAND,mFAAmF;AACnF,gGAAgG;AAChG,2CAAsC;AAEtC,SAAgB,IAAI;IAChB,OAAO,IAAA,mBAAO,EAAC,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,KAAK,IAAI,EAAE,CAAC;AAChB,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash).\n// File-edit payloads pass through untouched code-style validation is the separate rules hook.\nimport { runMain } from './hook-core';\n\nexport function main(): Promise<void> {\n return runMain('guards');\n}\n\nif (require.main === module) {\n void main();\n}\n"]}
1
+ {"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":";;;AAcA,oBAIC;AAjBD,qGAAqG;AACrG,qEAAqE;AACrE,EAAE;AACF,mGAAmG;AACnG,6FAA6F;AAC7F,mDAAmD;AACnD,4BAA0B;AAC1B,yCAAsC;AAEtC,yCAAsD;AACtD,iDAA0C;AAE1C,uQAAuQ;AAChQ,KAAK,UAAU,IAAI;IACtB,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,kBAAO,CAAC,CAAC;IACnC,MAAM,GAAG,CAAC,GAAG,CAAC,IAAI,uBAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED,oGAAoG;AACpG,qGAAqG;AACrG,wGAAwG;AACxG,mCAAmC;AACnC,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,4KAA4K;IAC5K,KAAK,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAQ,EAAE,GAAG,IAAI,0BAAe,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtF,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code / Codex PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash|Write|Edit|\n// MultiEdit|Read). Code-style validation is the separate rules hook.\n//\n// COMPOSITION ROOT, and deliberately nothing else: build the container, get the app, run it. Every\n// decision this binary makes lives behind HookApp; the only thing that distinguishes it from\n// rules-hook.ts is the one HookArgs it constructs.\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\n\nimport { HookApp, HookBootFailure } from './hook-app';\nimport { HookArgs } from './hook-outcome';\n\n// webpieces-disable no-function-outside-class -- this IS the bin's process entry point, named in package.json `exports` as ./claude-code-guards / ./claude-code-rules; a class here would be a namespace around one call and could not be the module's callable entry.\nexport async function main(): Promise<void> {\n const container = new Container({ autobind: true });\n const app = container.get(HookApp);\n await app.run(new HookArgs('guards'));\n}\n\n// `.catch` and not a bare `void main()`: a container that cannot be built throws BEFORE any HookApp\n// exists, and an unhandled rejection exits non-zero — which PreToolUse reads as a non-blocking error\n// and lets the tool call through. See HookBootFailure. `main` is async so a synchronous throw inside it\n// arrives here as a rejection too.\nif (require.main === module) {\n // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; HookBootFailure narrows it through toError(), which is the one place that job belongs\n void main().catch((err: unknown): void => { new HookBootFailure().report(err); });\n}\n"]}
@@ -0,0 +1,68 @@
1
+ import { HookMode } from '../core/types';
2
+ /**
3
+ * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.
4
+ *
5
+ * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from
6
+ * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the
7
+ * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather
8
+ * than whatever tree the suite happens to be running in. A golden computed against the live repo would
9
+ * change verdict with the branch you are standing on.
10
+ *
11
+ * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder
12
+ * living in one would never be type-checked by the build.
13
+ */
14
+ /** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */
15
+ export declare class GoldenFixture {
16
+ /** Stable key into `__goldens__/hook-app-goldens.json`. */
17
+ readonly name: string;
18
+ /** Which hook binary's mode — `guards` or `rules`. */
19
+ readonly mode: HookMode;
20
+ /**
21
+ * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures
22
+ * and a shape that cannot express "not JSON" would quietly drop the case that matters most.
23
+ */
24
+ readonly stdin: string;
25
+ /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */
26
+ readonly crashingRulesDir: boolean;
27
+ constructor(name: string, mode: HookMode, stdin: string, crashingRulesDir?: boolean);
28
+ }
29
+ /** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */
30
+ export declare class PreparedFixture {
31
+ readonly repo: string;
32
+ readonly root: string;
33
+ readonly stdin: string;
34
+ constructor(repo: string, root: string, stdin: string);
35
+ }
36
+ /**
37
+ * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are
38
+ * written with it and it is substituted in before the run; golden bytes are compared with the real
39
+ * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc
40
+ * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be
41
+ * a golden nobody else could run.
42
+ */
43
+ export declare const REPO_TOKEN = "<REPO>";
44
+ /**
45
+ * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a
46
+ * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only
47
+ * tool, malformed stdin, and a fail-closed crash.
48
+ */
49
+ export declare const GOLDEN_FIXTURES: readonly GoldenFixture[];
50
+ /**
51
+ * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.
52
+ *
53
+ * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the
54
+ * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's
55
+ * verdict — an order-dependent suite, which is the one kind of golden test worse than none.
56
+ *
57
+ * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to
58
+ * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo
59
+ * root. Without it every fixture denies with "run git from the repo root" instead of the verdict under
60
+ * test.
61
+ */
62
+ export declare class GoldenRepoBuilder {
63
+ private readonly configJson;
64
+ constructor();
65
+ build(fixture: GoldenFixture): PreparedFixture;
66
+ private repoConfig;
67
+ private git;
68
+ }
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GoldenRepoBuilder = exports.GOLDEN_FIXTURES = exports.REPO_TOKEN = exports.PreparedFixture = exports.GoldenFixture = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const os = tslib_1.__importStar(require("os"));
8
+ const path = tslib_1.__importStar(require("path"));
9
+ /**
10
+ * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.
11
+ *
12
+ * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from
13
+ * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the
14
+ * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather
15
+ * than whatever tree the suite happens to be running in. A golden computed against the live repo would
16
+ * change verdict with the branch you are standing on.
17
+ *
18
+ * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder
19
+ * living in one would never be type-checked by the build.
20
+ */
21
+ /** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */
22
+ class GoldenFixture {
23
+ /** Stable key into `__goldens__/hook-app-goldens.json`. */
24
+ name;
25
+ /** Which hook binary's mode — `guards` or `rules`. */
26
+ mode;
27
+ /**
28
+ * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures
29
+ * and a shape that cannot express "not JSON" would quietly drop the case that matters most.
30
+ */
31
+ stdin;
32
+ /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */
33
+ crashingRulesDir;
34
+ constructor(name, mode, stdin, crashingRulesDir = false) {
35
+ this.name = name;
36
+ this.mode = mode;
37
+ this.stdin = stdin;
38
+ this.crashingRulesDir = crashingRulesDir;
39
+ }
40
+ }
41
+ exports.GoldenFixture = GoldenFixture;
42
+ /** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */
43
+ class PreparedFixture {
44
+ repo;
45
+ root;
46
+ stdin;
47
+ constructor(repo, root, stdin) {
48
+ this.repo = repo;
49
+ this.root = root;
50
+ this.stdin = stdin;
51
+ }
52
+ }
53
+ exports.PreparedFixture = PreparedFixture;
54
+ /**
55
+ * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are
56
+ * written with it and it is substituted in before the run; golden bytes are compared with the real
57
+ * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc
58
+ * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be
59
+ * a golden nobody else could run.
60
+ */
61
+ exports.REPO_TOKEN = '<REPO>';
62
+ const CLAUDE_ENVELOPE = { hook_event_name: 'PreToolUse', session_id: 'sess-golden', transcript_path: '/dev/null', cwd: exports.REPO_TOKEN };
63
+ /**
64
+ * The Codex additions, MEASURED from codex-cli 0.151.0: it uses Claude's key names and merely ADDS
65
+ * `model`, `turn_id`, `tool_use_id` and `permission_mode`. `turn_id` is the one discriminator (see
66
+ * detect-ai.ts). `agent_id` empty ⇒ the coordinator, populated ⇒ a subagent — identical semantics in
67
+ * both harnesses.
68
+ */
69
+ const CODEX_ENVELOPE = { ...CLAUDE_ENVELOPE, model: 'gpt-5-codex', turn_id: 'turn-golden', tool_use_id: 'call_1', permission_mode: 'default', agent_type: 'default', agent_id: '' };
70
+ // Codex's edit tool. MEASURED: the tool is named `apply_patch`, it carries `tool_input.command` (not
71
+ // file_path), hunk headers are a bare `@@`, and ONE patch may carry many files with mixed operations.
72
+ const PATCH_ADD_JS = '*** Begin Patch\n*** Add File: src/foo.js\n+var x = 1;\n*** End Patch\n';
73
+ const PATCH_ADD_TS = '*** Begin Patch\n*** Add File: scripts/added.ts\n+export const b = 2;\n*** End Patch\n';
74
+ /**
75
+ * Serializes ONE PreToolUse envelope to the bytes a harness would put on stdin. A class rather than a
76
+ * module-scope function because that is the repo rule, and the one instance below is built at module
77
+ * load so the fixture table can stay a plain literal list.
78
+ */
79
+ class WirePayload {
80
+ // webpieces-disable no-any-unknown -- these objects ARE the wire envelope; JSON.stringify of a plain object is the payload under test, and naming a type for it would assert a shape the fixtures exist to state literally
81
+ write(envelope, toolName, toolInput) {
82
+ return JSON.stringify({ ...envelope, tool_name: toolName, tool_input: toolInput });
83
+ }
84
+ }
85
+ const WIRE = new WirePayload();
86
+ /**
87
+ * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a
88
+ * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only
89
+ * tool, malformed stdin, and a fail-closed crash.
90
+ */
91
+ exports.GOLDEN_FIXTURES = [
92
+ // ── Claude Code ────────────────────────────────────────────────────────────────────────────────
93
+ // A Bash deny. `git merge` is blocked on every branch, so this verdict does not depend on repo
94
+ // state — and a Bash deny is the ONE case that carries the red `systemMessage`.
95
+ new GoldenFixture('claude/bash-deny', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'git merge main' })),
96
+ new GoldenFixture('claude/bash-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'echo hi' })),
97
+ // The read-only tool: log-and-allow, and the only guard that can deny it is a stale `main`.
98
+ new GoldenFixture('claude/read-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Read', { file_path: `${exports.REPO_TOKEN}/f.txt` })),
99
+ // Write / Edit / MultiEdit denies — all three must emit NO systemMessage.
100
+ new GoldenFixture('claude/write-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/src/foo.js`, content: 'var x = 1;\n' })),
101
+ new GoldenFixture('claude/edit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Edit', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, old_string: 'const a = 1;', new_string: 'const { a } = b;' })),
102
+ new GoldenFixture('claude/multiedit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'MultiEdit', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, edits: [{ old_string: 'const a = 1;', new_string: 'const { a } = b;' }] })),
103
+ new GoldenFixture('claude/write-allow', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\n' })),
104
+ new GoldenFixture('claude/malformed', 'guards', 'not json at all'),
105
+ new GoldenFixture('claude/crash', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\n' }), true),
106
+ // ── Codex ──────────────────────────────────────────────────────────────────────────────────────
107
+ new GoldenFixture('codex/bash-deny', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'git merge main' })),
108
+ new GoldenFixture('codex/bash-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'echo hi' })),
109
+ // Codex has no Read tool: a read arrives as `Bash` running a pager, which read parity turns into a
110
+ // read-scoped verdict ON TOP of the bash guards.
111
+ new GoldenFixture('codex/read-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: `sed -n '1,240p' ${exports.REPO_TOKEN}/f.txt` })),
112
+ new GoldenFixture('codex/apply-patch-deny', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_JS })),
113
+ new GoldenFixture('codex/apply-patch-allow', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS })),
114
+ new GoldenFixture('codex/malformed', 'guards', '{"turn_id": broken'),
115
+ new GoldenFixture('codex/crash', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS }), true),
116
+ ];
117
+ // A rules module that blows up the moment it is required — the shortest honest way to reach the hook's
118
+ // fail-closed boundary from OUTSIDE the hook, i.e. without stubbing anything the pipeline owns.
119
+ const CRASHING_RULE_MODULE = "throw new Error('boom from a custom rule module');\n";
120
+ /**
121
+ * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.
122
+ *
123
+ * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the
124
+ * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's
125
+ * verdict — an order-dependent suite, which is the one kind of golden test worse than none.
126
+ *
127
+ * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to
128
+ * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo
129
+ * root. Without it every fixture denies with "run git from the repo root" instead of the verdict under
130
+ * test.
131
+ */
132
+ class GoldenRepoBuilder {
133
+ configJson;
134
+ constructor() {
135
+ this.configJson = fs.readFileSync(path.join(__dirname, '__goldens__', 'fixture-webpieces.config.json'), 'utf8');
136
+ }
137
+ build(fixture) {
138
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-hook-golden-')));
139
+ const repo = path.join(root, 'repo');
140
+ fs.mkdirSync(repo);
141
+ this.git(repo, 'init -q -b main');
142
+ // The developer's own commit hooks would otherwise fire inside this throwaway repo.
143
+ this.git(repo, 'config core.hooksPath /dev/null');
144
+ this.git(repo, 'config user.email t@t.co');
145
+ this.git(repo, 'config user.name tester');
146
+ fs.writeFileSync(path.join(repo, 'webpieces.config.json'), this.repoConfig(fixture));
147
+ fs.writeFileSync(path.join(repo, 'f.txt'), 'hello\n');
148
+ fs.mkdirSync(path.join(repo, 'scripts'));
149
+ fs.writeFileSync(path.join(repo, 'scripts', 'ok.ts'), 'const a = 1;\n');
150
+ if (fixture.crashingRulesDir) {
151
+ fs.mkdirSync(path.join(repo, 'wprules'));
152
+ fs.writeFileSync(path.join(repo, 'wprules', 'crash.js'), CRASHING_RULE_MODULE);
153
+ }
154
+ this.git(repo, 'add -A');
155
+ this.git(repo, 'commit -qm init');
156
+ // A local origin/main, so `origin/main..<branch>` resolves exactly as it does in a real clone.
157
+ this.git(repo, 'update-ref refs/remotes/origin/main HEAD');
158
+ // A feature branch, because that is where an agent actually works.
159
+ this.git(repo, 'checkout -q -b dean/fixture');
160
+ return new PreparedFixture(repo, root, fixture.stdin.split(exports.REPO_TOKEN).join(repo));
161
+ }
162
+ repoConfig(fixture) {
163
+ if (!fixture.crashingRulesDir)
164
+ return this.configJson;
165
+ // webpieces-disable no-any-unknown -- the frozen fixture config is data on disk; re-parsing it into a named type here would be a second declaration of a file whose whole point is being literal
166
+ const parsed = JSON.parse(this.configJson);
167
+ parsed['rulesDir'] = ['wprules'];
168
+ return JSON.stringify(parsed, null, 4);
169
+ }
170
+ git(repo, args) {
171
+ (0, child_process_1.execSync)(`git ${args}`, { cwd: repo, encoding: 'utf8' });
172
+ }
173
+ }
174
+ exports.GoldenRepoBuilder = GoldenRepoBuilder;
175
+ //# sourceMappingURL=hook-app-fixtures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-app-fixtures.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-app-fixtures.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\n\nimport { HookMode } from '../core/types';\n\n/**\n * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.\n *\n * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from\n * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the\n * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather\n * than whatever tree the suite happens to be running in. A golden computed against the live repo would\n * change verdict with the branch you are standing on.\n *\n * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder\n * living in one would never be type-checked by the build.\n */\n\n/** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */\nexport class GoldenFixture {\n /** Stable key into `__goldens__/hook-app-goldens.json`. */\n readonly name: string;\n /** Which hook binary's mode — `guards` or `rules`. */\n readonly mode: HookMode;\n /**\n * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures\n * and a shape that cannot express \"not JSON\" would quietly drop the case that matters most.\n */\n readonly stdin: string;\n /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */\n readonly crashingRulesDir: boolean;\n\n constructor(name: string, mode: HookMode, stdin: string, crashingRulesDir: boolean = false) {\n this.name = name;\n this.mode = mode;\n this.stdin = stdin;\n this.crashingRulesDir = crashingRulesDir;\n }\n}\n\n/** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */\nexport class PreparedFixture {\n readonly repo: string;\n readonly root: string;\n readonly stdin: string;\n\n constructor(repo: string, root: string, stdin: string) {\n this.repo = repo;\n this.root = root;\n this.stdin = stdin;\n }\n}\n\n/**\n * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are\n * written with it and it is substituted in before the run; golden bytes are compared with the real\n * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc\n * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be\n * a golden nobody else could run.\n */\nexport const REPO_TOKEN = '<REPO>';\n\nconst CLAUDE_ENVELOPE = { hook_event_name: 'PreToolUse', session_id: 'sess-golden', transcript_path: '/dev/null', cwd: REPO_TOKEN };\n\n/**\n * The Codex additions, MEASURED from codex-cli 0.151.0: it uses Claude's key names and merely ADDS\n * `model`, `turn_id`, `tool_use_id` and `permission_mode`. `turn_id` is the one discriminator (see\n * detect-ai.ts). `agent_id` empty ⇒ the coordinator, populated ⇒ a subagent — identical semantics in\n * both harnesses.\n */\nconst CODEX_ENVELOPE = { ...CLAUDE_ENVELOPE, model: 'gpt-5-codex', turn_id: 'turn-golden', tool_use_id: 'call_1', permission_mode: 'default', agent_type: 'default', agent_id: '' };\n\n// Codex's edit tool. MEASURED: the tool is named `apply_patch`, it carries `tool_input.command` (not\n// file_path), hunk headers are a bare `@@`, and ONE patch may carry many files with mixed operations.\nconst PATCH_ADD_JS = '*** Begin Patch\\n*** Add File: src/foo.js\\n+var x = 1;\\n*** End Patch\\n';\nconst PATCH_ADD_TS = '*** Begin Patch\\n*** Add File: scripts/added.ts\\n+export const b = 2;\\n*** End Patch\\n';\n\n/**\n * Serializes ONE PreToolUse envelope to the bytes a harness would put on stdin. A class rather than a\n * module-scope function because that is the repo rule, and the one instance below is built at module\n * load so the fixture table can stay a plain literal list.\n */\nclass WirePayload {\n // webpieces-disable no-any-unknown -- these objects ARE the wire envelope; JSON.stringify of a plain object is the payload under test, and naming a type for it would assert a shape the fixtures exist to state literally\n write(envelope: Record<string, unknown>, toolName: string, toolInput: Record<string, unknown>): string {\n return JSON.stringify({ ...envelope, tool_name: toolName, tool_input: toolInput });\n }\n}\n\nconst WIRE = new WirePayload();\n\n/**\n * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a\n * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only\n * tool, malformed stdin, and a fail-closed crash.\n */\nexport const GOLDEN_FIXTURES: readonly GoldenFixture[] = [\n // ── Claude Code ────────────────────────────────────────────────────────────────────────────────\n // A Bash deny. `git merge` is blocked on every branch, so this verdict does not depend on repo\n // state — and a Bash deny is the ONE case that carries the red `systemMessage`.\n new GoldenFixture('claude/bash-deny', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'git merge main' })),\n new GoldenFixture('claude/bash-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'echo hi' })),\n // The read-only tool: log-and-allow, and the only guard that can deny it is a stale `main`.\n new GoldenFixture('claude/read-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Read', { file_path: `${REPO_TOKEN}/f.txt` })),\n // Write / Edit / MultiEdit denies — all three must emit NO systemMessage.\n new GoldenFixture('claude/write-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/src/foo.js`, content: 'var x = 1;\\n' })),\n new GoldenFixture('claude/edit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Edit', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, old_string: 'const a = 1;', new_string: 'const { a } = b;' })),\n new GoldenFixture('claude/multiedit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'MultiEdit', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, edits: [{ old_string: 'const a = 1;', new_string: 'const { a } = b;' }] })),\n new GoldenFixture('claude/write-allow', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\\n' })),\n new GoldenFixture('claude/malformed', 'guards', 'not json at all'),\n new GoldenFixture('claude/crash', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\\n' }), true),\n\n // ── Codex ──────────────────────────────────────────────────────────────────────────────────────\n new GoldenFixture('codex/bash-deny', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'git merge main' })),\n new GoldenFixture('codex/bash-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'echo hi' })),\n // Codex has no Read tool: a read arrives as `Bash` running a pager, which read parity turns into a\n // read-scoped verdict ON TOP of the bash guards.\n new GoldenFixture('codex/read-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: `sed -n '1,240p' ${REPO_TOKEN}/f.txt` })),\n new GoldenFixture('codex/apply-patch-deny', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_JS })),\n new GoldenFixture('codex/apply-patch-allow', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS })),\n new GoldenFixture('codex/malformed', 'guards', '{\"turn_id\": broken'),\n new GoldenFixture('codex/crash', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS }), true),\n];\n\n// A rules module that blows up the moment it is required — the shortest honest way to reach the hook's\n// fail-closed boundary from OUTSIDE the hook, i.e. without stubbing anything the pipeline owns.\nconst CRASHING_RULE_MODULE = \"throw new Error('boom from a custom rule module');\\n\";\n\n/**\n * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.\n *\n * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the\n * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's\n * verdict — an order-dependent suite, which is the one kind of golden test worse than none.\n *\n * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to\n * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo\n * root. Without it every fixture denies with \"run git from the repo root\" instead of the verdict under\n * test.\n */\nexport class GoldenRepoBuilder {\n private readonly configJson: string;\n\n constructor() {\n this.configJson = fs.readFileSync(path.join(__dirname, '__goldens__', 'fixture-webpieces.config.json'), 'utf8');\n }\n\n build(fixture: GoldenFixture): PreparedFixture {\n const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-hook-golden-')));\n const repo = path.join(root, 'repo');\n fs.mkdirSync(repo);\n this.git(repo, 'init -q -b main');\n // The developer's own commit hooks would otherwise fire inside this throwaway repo.\n this.git(repo, 'config core.hooksPath /dev/null');\n this.git(repo, 'config user.email t@t.co');\n this.git(repo, 'config user.name tester');\n fs.writeFileSync(path.join(repo, 'webpieces.config.json'), this.repoConfig(fixture));\n fs.writeFileSync(path.join(repo, 'f.txt'), 'hello\\n');\n fs.mkdirSync(path.join(repo, 'scripts'));\n fs.writeFileSync(path.join(repo, 'scripts', 'ok.ts'), 'const a = 1;\\n');\n if (fixture.crashingRulesDir) {\n fs.mkdirSync(path.join(repo, 'wprules'));\n fs.writeFileSync(path.join(repo, 'wprules', 'crash.js'), CRASHING_RULE_MODULE);\n }\n this.git(repo, 'add -A');\n this.git(repo, 'commit -qm init');\n // A local origin/main, so `origin/main..<branch>` resolves exactly as it does in a real clone.\n this.git(repo, 'update-ref refs/remotes/origin/main HEAD');\n // A feature branch, because that is where an agent actually works.\n this.git(repo, 'checkout -q -b dean/fixture');\n return new PreparedFixture(repo, root, fixture.stdin.split(REPO_TOKEN).join(repo));\n }\n\n private repoConfig(fixture: GoldenFixture): string {\n if (!fixture.crashingRulesDir) return this.configJson;\n // webpieces-disable no-any-unknown -- the frozen fixture config is data on disk; re-parsing it into a named type here would be a second declaration of a file whose whole point is being literal\n const parsed = JSON.parse(this.configJson) as Record<string, unknown>;\n parsed['rulesDir'] = ['wprules'];\n return JSON.stringify(parsed, null, 4);\n }\n\n private git(repo: string, args: string): void {\n execSync(`git ${args}`, { cwd: repo, encoding: 'utf8' });\n }\n}\n"]}
@@ -0,0 +1,61 @@
1
+ import { HookArgs } from './hook-outcome';
2
+ import { HookStdinSource, HookStdoutSink, HookProcessExit } from './hook-ports';
3
+ /**
4
+ * THE COMPOSITION ROOT'S APP — one PreToolUse invocation, end to end.
5
+ *
6
+ * Production is three lines in `guards-hook.ts` / `rules-hook.ts`:
7
+ *
8
+ * const container = new Container({ autobind: true });
9
+ * const app = container.get(HookApp);
10
+ * await app.run(new HookArgs('guards'));
11
+ *
12
+ * and a test is the SAME three lines with the ports rebound to doubles — canned stdin, a captured
13
+ * stdout, a recorded exit code. That is the whole difference, and it is the point: the test boundary
14
+ * is cut JUST ABOVE the injection point, so the seam a test drives is the seam production drives.
15
+ *
16
+ * What this replaces: `runMain(mode)`, which read stdin itself and reached `process.stdout.write` /
17
+ * `process.exit` from a dozen frames down. `runMain` is DELETED, not kept alongside — two spellings of
18
+ * one entry point is the shim shape this repo rejects outright (see CLAUDE.md, "NO webpieces surface
19
+ * is released backwards-compatible"). Nothing outside this file names it any more.
20
+ *
21
+ * The order of observable effects is unchanged from `runMain`: the invocation's audit line is flushed
22
+ * at the emit boundary inside the pipeline, the decision bytes are written next, and the process exits
23
+ * last through the injected exit port.
24
+ */
25
+ export declare class HookApp {
26
+ private readonly stdin;
27
+ private readonly stdout;
28
+ private readonly processExit;
29
+ constructor(stdin: HookStdinSource, stdout: HookStdoutSink, processExit: HookProcessExit);
30
+ run(args: HookArgs): Promise<void>;
31
+ /**
32
+ * THE FAIL-CLOSED BOUNDARY FOR THE READ ITSELF, and the reason this is a separate method.
33
+ *
34
+ * `runMain` had the stdin read INSIDE the try whose catch produced a deny, so a failure there was
35
+ * still a structured block. Moving the read behind a port would have quietly narrowed that: a
36
+ * rejected read (or anything else thrown before the pipeline starts) would escape `run`, land as an
37
+ * unhandled rejection, and exit non-zero — which PreToolUse reads as a NON-BLOCKING error and lets
38
+ * the tool call THROUGH. That is the exact inversion of "a broken hook never silently lets an edit
39
+ * through", and no golden could catch it, because the goldens substitute this very port.
40
+ *
41
+ * So the try is restored one level out, around the read AND the pipeline, and it emits the same
42
+ * bytes `denyForCrash` emits for a null event.
43
+ */
44
+ private decide;
45
+ }
46
+ /**
47
+ * THE LAST-RESORT FAIL-CLOSED BOUNDARY: the composition root itself could not run.
48
+ *
49
+ * `new Container(...)` and `container.get(HookApp)` happen BEFORE any HookApp exists to catch for
50
+ * them, so an unresolvable binding (a missing decorator, a stripped `design:paramtypes`) would exit
51
+ * non-zero with a stack on stderr — and a non-zero exit is a non-blocking error, so every guarded tool
52
+ * call would sail through unjudged for as long as the defect lasted. Low probability; total
53
+ * consequence. One shared class rather than a copy in each bin, so the two can never drift.
54
+ *
55
+ * It writes through `process` directly, and that is correct rather than a leak: by construction there
56
+ * is no container here to have handed it a port, and this is the same designated terminal boundary the
57
+ * ports themselves wrap.
58
+ */
59
+ export declare class HookBootFailure {
60
+ report(err: unknown): void;
61
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HookBootFailure = exports.HookApp = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const inversify_1 = require("inversify");
6
+ const hook_core_1 = require("./hook-core");
7
+ const agent_response_1 = require("./agent-response");
8
+ const hook_ports_1 = require("./hook-ports");
9
+ const to_error_1 = require("../core/to-error");
10
+ // The reason text a crash surfaces to the agent. ONE literal, used by both fail-closed boundaries
11
+ // below and worded identically to `denyForCrash`'s so the audit trail reads the same whichever of them
12
+ // caught it.
13
+ const CRASH_PREFIX = '[ai-hooks] hook crashed unexpectedly — failing closed: ';
14
+ /**
15
+ * THE COMPOSITION ROOT'S APP — one PreToolUse invocation, end to end.
16
+ *
17
+ * Production is three lines in `guards-hook.ts` / `rules-hook.ts`:
18
+ *
19
+ * const container = new Container({ autobind: true });
20
+ * const app = container.get(HookApp);
21
+ * await app.run(new HookArgs('guards'));
22
+ *
23
+ * and a test is the SAME three lines with the ports rebound to doubles — canned stdin, a captured
24
+ * stdout, a recorded exit code. That is the whole difference, and it is the point: the test boundary
25
+ * is cut JUST ABOVE the injection point, so the seam a test drives is the seam production drives.
26
+ *
27
+ * What this replaces: `runMain(mode)`, which read stdin itself and reached `process.stdout.write` /
28
+ * `process.exit` from a dozen frames down. `runMain` is DELETED, not kept alongside — two spellings of
29
+ * one entry point is the shim shape this repo rejects outright (see CLAUDE.md, "NO webpieces surface
30
+ * is released backwards-compatible"). Nothing outside this file names it any more.
31
+ *
32
+ * The order of observable effects is unchanged from `runMain`: the invocation's audit line is flushed
33
+ * at the emit boundary inside the pipeline, the decision bytes are written next, and the process exits
34
+ * last through the injected exit port.
35
+ */
36
+ let HookApp = class HookApp {
37
+ stdin;
38
+ stdout;
39
+ processExit;
40
+ constructor(stdin, stdout, processExit) {
41
+ this.stdin = stdin;
42
+ this.stdout = stdout;
43
+ this.processExit = processExit;
44
+ }
45
+ async run(args) {
46
+ const outcome = await this.decide(args);
47
+ // An ALLOW writes NOTHING — a silent exit 0 is the allow in the PreToolUse protocol, and an
48
+ // empty write would still be a write on a pipe somebody is parsing. Guarded here rather than
49
+ // in the sink so the sink stays a dumb port.
50
+ if (outcome.stdout !== '')
51
+ this.stdout.write(outcome.stdout);
52
+ this.processExit.exit(outcome.exitCode);
53
+ }
54
+ /**
55
+ * THE FAIL-CLOSED BOUNDARY FOR THE READ ITSELF, and the reason this is a separate method.
56
+ *
57
+ * `runMain` had the stdin read INSIDE the try whose catch produced a deny, so a failure there was
58
+ * still a structured block. Moving the read behind a port would have quietly narrowed that: a
59
+ * rejected read (or anything else thrown before the pipeline starts) would escape `run`, land as an
60
+ * unhandled rejection, and exit non-zero — which PreToolUse reads as a NON-BLOCKING error and lets
61
+ * the tool call THROUGH. That is the exact inversion of "a broken hook never silently lets an edit
62
+ * through", and no golden could catch it, because the goldens substitute this very port.
63
+ *
64
+ * So the try is restored one level out, around the read AND the pipeline, and it emits the same
65
+ * bytes `denyForCrash` emits for a null event.
66
+ */
67
+ async decide(args) {
68
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
69
+ try {
70
+ const raw = await this.stdin.read();
71
+ return (0, hook_core_1.runPipeline)(raw, args.mode);
72
+ }
73
+ catch (err) {
74
+ const error = (0, to_error_1.toError)(err);
75
+ return (0, agent_response_1.denyOutcome)(null, `${CRASH_PREFIX}${error.message}`, 'hook-crash');
76
+ }
77
+ }
78
+ };
79
+ exports.HookApp = HookApp;
80
+ exports.HookApp = HookApp = tslib_1.__decorate([
81
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
82
+ tslib_1.__metadata("design:paramtypes", [hook_ports_1.HookStdinSource, hook_ports_1.HookStdoutSink, hook_ports_1.HookProcessExit])
83
+ ], HookApp);
84
+ /**
85
+ * THE LAST-RESORT FAIL-CLOSED BOUNDARY: the composition root itself could not run.
86
+ *
87
+ * `new Container(...)` and `container.get(HookApp)` happen BEFORE any HookApp exists to catch for
88
+ * them, so an unresolvable binding (a missing decorator, a stripped `design:paramtypes`) would exit
89
+ * non-zero with a stack on stderr — and a non-zero exit is a non-blocking error, so every guarded tool
90
+ * call would sail through unjudged for as long as the defect lasted. Low probability; total
91
+ * consequence. One shared class rather than a copy in each bin, so the two can never drift.
92
+ *
93
+ * It writes through `process` directly, and that is correct rather than a leak: by construction there
94
+ * is no container here to have handed it a port, and this is the same designated terminal boundary the
95
+ * ports themselves wrap.
96
+ */
97
+ class HookBootFailure {
98
+ // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; toError() below is the one place that narrowing belongs
99
+ report(err) {
100
+ const error = (0, to_error_1.toError)(err);
101
+ // `null` event ⇒ no `systemMessage`. Deliberate: we never parsed a payload, so we do not know
102
+ // whether this was a Bash call, and inventing the Bash-shaped deny would be a guess.
103
+ // webpieces-disable no-process-exit-outside-main -- the hook's exit code IS the Claude Code PreToolUse protocol (exit 0 + JSON = a block); this is the last-resort terminal boundary, reached only when no container could be built to inject a port.
104
+ process.stdout.write((0, agent_response_1.denyJson)(null, `${CRASH_PREFIX}${error.message}`) + '\n');
105
+ // webpieces-disable no-process-exit-outside-main -- same terminal boundary; exiting 0 is what makes this a BLOCK rather than a non-blocking error that lets the tool call through.
106
+ process.exit(0);
107
+ }
108
+ }
109
+ exports.HookBootFailure = HookBootFailure;
110
+ //# sourceMappingURL=hook-app.js.map