@lannguyensi/harness 0.41.0 → 0.43.0

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 (137) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +9 -0
  3. package/dist/cli/adopt/derive.d.ts +15 -0
  4. package/dist/cli/adopt/derive.js +34 -7
  5. package/dist/cli/adopt/derive.js.map +1 -1
  6. package/dist/cli/adopt/index.d.ts +30 -0
  7. package/dist/cli/adopt/index.js +23 -3
  8. package/dist/cli/adopt/index.js.map +1 -1
  9. package/dist/cli/apply/generate-settings.d.ts +14 -1
  10. package/dist/cli/apply/generate-settings.js +7 -3
  11. package/dist/cli/apply/generate-settings.js.map +1 -1
  12. package/dist/cli/approve/branch-protection.js +1 -1
  13. package/dist/cli/approve/branch-protection.js.map +1 -1
  14. package/dist/cli/approve/stdin-report.js +2 -0
  15. package/dist/cli/approve/stdin-report.js.map +1 -1
  16. package/dist/cli/doctor/claude-mcp.d.ts +50 -0
  17. package/dist/cli/doctor/claude-mcp.js +170 -0
  18. package/dist/cli/doctor/claude-mcp.js.map +1 -0
  19. package/dist/cli/doctor/format.js +37 -0
  20. package/dist/cli/doctor/format.js.map +1 -1
  21. package/dist/cli/doctor/index.d.ts +10 -0
  22. package/dist/cli/doctor/index.js +24 -0
  23. package/dist/cli/doctor/index.js.map +1 -1
  24. package/dist/cli/doctor/npm-bin-path.js +42 -0
  25. package/dist/cli/doctor/npm-bin-path.js.map +1 -1
  26. package/dist/cli/doctor/types.d.ts +11 -0
  27. package/dist/cli/doctor/types.js.map +1 -1
  28. package/dist/cli/dry-run.js +13 -1
  29. package/dist/cli/dry-run.js.map +1 -1
  30. package/dist/cli/index.js +253 -5
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/cli/init/agent-tasks-auth.js +32 -0
  33. package/dist/cli/init/agent-tasks-auth.js.map +1 -1
  34. package/dist/cli/init/composer.js +9 -15
  35. package/dist/cli/init/composer.js.map +1 -1
  36. package/dist/cli/init/dependencies.js +21 -0
  37. package/dist/cli/init/dependencies.js.map +1 -1
  38. package/dist/cli/init/detect.d.ts +20 -3
  39. package/dist/cli/init/detect.js +34 -36
  40. package/dist/cli/init/detect.js.map +1 -1
  41. package/dist/cli/init/interactive.d.ts +33 -0
  42. package/dist/cli/init/interactive.js +399 -5
  43. package/dist/cli/init/interactive.js.map +1 -1
  44. package/dist/cli/init/profiles.d.ts +1 -1
  45. package/dist/cli/init/profiles.js +2 -2
  46. package/dist/cli/init/templates.d.ts +1 -1
  47. package/dist/cli/init/templates.js +111 -32
  48. package/dist/cli/init/templates.js.map +1 -1
  49. package/dist/cli/main.js +10 -0
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/pack/hook-branch-protection.js +1 -1
  52. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  53. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +30 -0
  54. package/dist/cli/pack/hook-post-merge-gate-record.js +153 -0
  55. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -0
  56. package/dist/cli/pack/hook-post-merge-gate.d.ts +28 -0
  57. package/dist/cli/pack/hook-post-merge-gate.js +244 -0
  58. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -0
  59. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +149 -3
  60. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  61. package/dist/cli/pack/hook-solution-acceptance.js +1 -1
  62. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  63. package/dist/cli/pack/understanding-report-schema-hint.js +1 -1
  64. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  65. package/dist/cli/policy/intercept.js +77 -10
  66. package/dist/cli/policy/intercept.js.map +1 -1
  67. package/dist/cli/record/index.d.ts +64 -0
  68. package/dist/cli/record/index.js +211 -0
  69. package/dist/cli/record/index.js.map +1 -0
  70. package/dist/cli/session-start/index.d.ts +7 -0
  71. package/dist/cli/session-start/index.js +133 -38
  72. package/dist/cli/session-start/index.js.map +1 -1
  73. package/dist/cli/session-start/toolchain-parity.d.ts +149 -0
  74. package/dist/cli/session-start/toolchain-parity.js +618 -0
  75. package/dist/cli/session-start/toolchain-parity.js.map +1 -0
  76. package/dist/cli/uninstall/index.d.ts +73 -1
  77. package/dist/cli/uninstall/index.js +134 -2
  78. package/dist/cli/uninstall/index.js.map +1 -1
  79. package/dist/cli/validate/checks.js +2 -2
  80. package/dist/cli/validate/checks.js.map +1 -1
  81. package/dist/io/claude-mcp.d.ts +258 -0
  82. package/dist/io/claude-mcp.js +544 -0
  83. package/dist/io/claude-mcp.js.map +1 -0
  84. package/dist/policies/ledger-client.d.ts +34 -1
  85. package/dist/policies/ledger-client.js +21 -16
  86. package/dist/policies/ledger-client.js.map +1 -1
  87. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +1 -1
  88. package/dist/policy-packs/builtin/branch-protection-runtime.js +1 -1
  89. package/dist/policy-packs/builtin/branch-protection.js +2 -2
  90. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  91. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +195 -0
  92. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +290 -0
  93. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -0
  94. package/dist/policy-packs/builtin/post-merge-gate.d.ts +56 -0
  95. package/dist/policy-packs/builtin/post-merge-gate.js +291 -0
  96. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -0
  97. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +29 -14
  98. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  99. package/dist/policy-packs/builtin/solution-acceptance.js +1 -1
  100. package/dist/policy-packs/registry.d.ts +1 -1
  101. package/dist/policy-packs/registry.js +12 -0
  102. package/dist/policy-packs/registry.js.map +1 -1
  103. package/dist/runtime/bash-match-facts.d.ts +58 -0
  104. package/dist/runtime/bash-match-facts.js +194 -0
  105. package/dist/runtime/bash-match-facts.js.map +1 -0
  106. package/dist/runtime/bash-match-registry.d.ts +75 -0
  107. package/dist/runtime/bash-match-registry.js +359 -0
  108. package/dist/runtime/bash-match-registry.js.map +1 -0
  109. package/dist/runtime/bash-prefix-parse.js +23 -0
  110. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  111. package/dist/runtime/command-normalize.d.ts +92 -0
  112. package/dist/runtime/command-normalize.js +1182 -0
  113. package/dist/runtime/command-normalize.js.map +1 -0
  114. package/dist/runtime/git-context.d.ts +50 -0
  115. package/dist/runtime/git-context.js +108 -1
  116. package/dist/runtime/git-context.js.map +1 -1
  117. package/dist/runtime/hermetic-spawn-guard.d.ts +25 -0
  118. package/dist/runtime/hermetic-spawn-guard.js +136 -0
  119. package/dist/runtime/hermetic-spawn-guard.js.map +1 -0
  120. package/dist/runtime/intercept.d.ts +39 -1
  121. package/dist/runtime/intercept.js +33 -4
  122. package/dist/runtime/intercept.js.map +1 -1
  123. package/dist/runtime/ledger-writer.d.ts +27 -0
  124. package/dist/runtime/ledger-writer.js +46 -0
  125. package/dist/runtime/ledger-writer.js.map +1 -0
  126. package/dist/runtime/read-only-bash.d.ts +43 -0
  127. package/dist/runtime/read-only-bash.js +133 -3
  128. package/dist/runtime/read-only-bash.js.map +1 -1
  129. package/dist/schema/index.d.ts +41 -0
  130. package/dist/schema/index.js +5 -0
  131. package/dist/schema/index.js.map +1 -1
  132. package/dist/schema/policies.js +1 -1
  133. package/dist/schema/policies.js.map +1 -1
  134. package/dist/schema/toolchain-parity.d.ts +18 -0
  135. package/dist/schema/toolchain-parity.js +35 -0
  136. package/dist/schema/toolchain-parity.js.map +1 -0
  137. package/package.json +5 -2
@@ -0,0 +1,153 @@
1
+ // `harness pack hook post-merge-gate-record` — PostToolUse producer for
2
+ // the `post-merge-gate` policy pack.
3
+ //
4
+ // Receives Claude Code's PostToolUse event JSON on stdin. Fires only when
5
+ // the just-completed tool was Bash AND the command matched `gh pr merge`
6
+ // AND the merge is CONFIRMED by one of two contracts (see
7
+ // `resolveMergeConfirmation` in post-merge-gate-runtime.ts, "PAYLOAD
8
+ // REALITY" follow-up):
9
+ //
10
+ // Contract A (original, unchanged): `tool_output.exit_code === 0`.
11
+ // Contract B (2.1.218 payload reality): `tool_response` present with
12
+ // `interrupted === false` AND `stdout`+`stderr` containing `gh pr
13
+ // merge`'s own past-tense success sentence (all three merge methods).
14
+ //
15
+ // Contract A wins whenever it resolves to any definite verdict (success
16
+ // OR failure); Contract B is consulted only when Contract A's exit_code
17
+ // is entirely unresolvable. On a confirmed merge, records a
18
+ // `post-merge-gate:merged:<repo>:<branch>:<sha>` fact (plus PR number and
19
+ // timestamp, audit-only) to the evidence ledger via the same
20
+ // Trusted-Writer path `harness session-start branch-check` /
21
+ // `harness record *` use (`resolveManifestLedgerWriter` /
22
+ // `addLedgerFact`) — never an agent-issued `ledger_add`.
23
+ //
24
+ // `<sha>` is the LOCAL branch tip observed right after the tool ran:
25
+ // `gh pr merge` merges the PR on the remote side and does not itself move
26
+ // the local branch pointer, so this IS the exact commit that got merged
27
+ // (see post-merge-gate-runtime.ts's header for the full squash-fest
28
+ // rationale — no ancestry walk, no `git` subprocess).
29
+ //
30
+ // Every non-match / failure path is a no-op: wrong tool, non-matching
31
+ // command, neither contract confirms, an unresolvable git context, no
32
+ // session id, a manifest/ledger failure. `PostToolUse` is `blocking:false`
33
+ // by contract (see the pack's Hook entry) so none of these ever break the
34
+ // session; the only observable effect of a miss is that the blocker has
35
+ // no merged-tip fact to compare against for this particular merge.
36
+ import { buildMergedTagContent, GH_PR_MERGE_BASH_RE, PACK_NAME, resolveMergeConfirmation, } from "../../policy-packs/builtin/post-merge-gate-runtime.js";
37
+ import { resolveGitContext } from "../../runtime/git-context.js";
38
+ import { resolveManifestLedgerWriter } from "../../runtime/ledger-writer.js";
39
+ import { checkHookPause, loadManifestOrInjected, readStdin } from "./hook-bootstrap.js";
40
+ const LEDGER_SOURCE = "harness-pack-hook-post-merge-gate-record";
41
+ function bashCommandOf(toolInput) {
42
+ if (typeof toolInput !== "object" || toolInput === null)
43
+ return "";
44
+ const cmd = toolInput["command"];
45
+ return typeof cmd === "string" ? cmd : "";
46
+ }
47
+ export async function runPackHookPostMergeGateRecordCli(opts = {}) {
48
+ const stdin = opts.stdin ?? process.stdin;
49
+ const stderr = opts.stderr ?? process.stderr;
50
+ const note = (msg) => {
51
+ stderr.write(`harness pack hook post-merge-gate-record: ${msg}\n`);
52
+ };
53
+ const raw = await readStdin(stdin);
54
+ let event = {};
55
+ try {
56
+ event = JSON.parse(raw.trim() || "{}");
57
+ }
58
+ catch {
59
+ const diagnostic = "malformed event JSON, skipping";
60
+ note(diagnostic);
61
+ return { exitCode: 0, wrote: false, diagnostic };
62
+ }
63
+ // Pause sentinel — mirrors every other pack hook's operator kill-switch.
64
+ if (checkHookPause(PACK_NAME, stderr, opts, opts.generatedDir).paused) {
65
+ const diagnostic = "harness paused; post-merge-gate-record skipping without evaluating.";
66
+ note(diagnostic);
67
+ return { exitCode: 0, wrote: false, diagnostic };
68
+ }
69
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "";
70
+ if (toolName !== "Bash") {
71
+ const diagnostic = `tool ${toolName || "(unknown)"} is not Bash, skipping`;
72
+ note(diagnostic);
73
+ return { exitCode: 0, wrote: false, diagnostic };
74
+ }
75
+ const command = bashCommandOf(event.tool_input);
76
+ if (!command || !GH_PR_MERGE_BASH_RE.test(command)) {
77
+ const diagnostic = "command did not match gh pr merge, skipping";
78
+ note(diagnostic);
79
+ return { exitCode: 0, wrote: false, diagnostic };
80
+ }
81
+ // The confirmed-success gate: neither contract confirming — including
82
+ // any unresolvable / unexpected payload shape on either — writes NO
83
+ // fact. Fail-safe against a false "merged" record, which would be a
84
+ // self-lock in the wrong direction (03-decisions.md). See
85
+ // resolveMergeConfirmation's own doc comment for the dual-contract /
86
+ // "Contract A wins" ordering decision.
87
+ const confirmation = resolveMergeConfirmation(event.tool_output, event.tool_response, command);
88
+ if (!confirmation.confirmed) {
89
+ const diagnostic = `not a confirmed merge success (${confirmation.reason}); skipping (no fact written)`;
90
+ note(diagnostic);
91
+ return { exitCode: 0, wrote: false, diagnostic };
92
+ }
93
+ const cwd = typeof opts.cwd === "string" && opts.cwd.length > 0
94
+ ? opts.cwd
95
+ : typeof event.cwd === "string" && event.cwd.length > 0
96
+ ? event.cwd
97
+ : process.cwd();
98
+ const { repo, branch, sha } = resolveGitContext(cwd);
99
+ if (repo === "" || branch === "" || sha === "") {
100
+ const diagnostic = `cannot resolve git context for ${cwd} ` +
101
+ `(repo=${JSON.stringify(repo)} branch=${JSON.stringify(branch)} sha=${JSON.stringify(sha)}); ` +
102
+ `skipping (no fact written)`;
103
+ note(diagnostic);
104
+ return { exitCode: 0, wrote: false, diagnostic };
105
+ }
106
+ const sessionId = (typeof event.session_id === "string" && event.session_id.length > 0
107
+ ? event.session_id
108
+ : undefined) ??
109
+ process.env.CLAUDE_CODE_SESSION_ID ??
110
+ process.env.CLAUDE_SESSION_ID ??
111
+ "";
112
+ if (sessionId === "") {
113
+ const diagnostic = "no session_id resolvable from stdin or $CLAUDE_CODE_SESSION_ID/$CLAUDE_SESSION_ID; skipping (no fact written)";
114
+ note(diagnostic);
115
+ return { exitCode: 0, wrote: false, diagnostic };
116
+ }
117
+ // PR number: resolveMergeConfirmation already applied the binding
118
+ // resolution order (command-first, gh-success-sentence fallback only
119
+ // for Contract B; Contract A stays command-only, unchanged).
120
+ const whenIso = (opts.now ?? new Date()).toISOString();
121
+ const content = buildMergedTagContent({ repo, branch, sha, pr: confirmation.pr, whenIso });
122
+ let writeLedger = opts.writeLedger;
123
+ if (!writeLedger) {
124
+ let manifest;
125
+ try {
126
+ ({ manifest } = loadManifestOrInjected(opts, opts.manifest));
127
+ }
128
+ catch (err) {
129
+ const diagnostic = `manifest load failed (${err.message}); skipping (no fact written)`;
130
+ note(diagnostic);
131
+ return { exitCode: 0, wrote: false, diagnostic };
132
+ }
133
+ const resolved = resolveManifestLedgerWriter(manifest, {
134
+ ...(opts.ledgerTimeoutMs !== undefined ? { ledgerTimeoutMs: opts.ledgerTimeoutMs } : {}),
135
+ });
136
+ if (!resolved.ok) {
137
+ const diagnostic = `${resolved.reason}; cannot record ${content}`;
138
+ note(diagnostic);
139
+ return { exitCode: 0, wrote: false, diagnostic };
140
+ }
141
+ writeLedger = resolved.write;
142
+ }
143
+ const result = await writeLedger({ sessionId, content, source: LEDGER_SOURCE });
144
+ if (!result.ok) {
145
+ const diagnostic = `ledger write failed: ${result.reason ?? "unknown error"}`;
146
+ note(diagnostic);
147
+ return { exitCode: 0, wrote: false, diagnostic };
148
+ }
149
+ const diagnostic = `recorded ${content} for session ${sessionId}`;
150
+ note(diagnostic);
151
+ return { exitCode: 0, wrote: true, diagnostic };
152
+ }
153
+ //# sourceMappingURL=hook-post-merge-gate-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-post-merge-gate-record.js","sourceRoot":"","sources":["../../../src/cli/pack/hook-post-merge-gate-record.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ import { type LedgerEntry } from "../../policies/index.js";
2
+ import { type Manifest } from "../../schema/index.js";
3
+ import { type LoaderOptions } from "../loader.js";
4
+ export interface PackHookPostMergeGateOptions extends LoaderOptions {
5
+ /** Defaults to process.stdin. */
6
+ stdin?: NodeJS.ReadableStream;
7
+ /** Defaults to process.stdout. */
8
+ stdout?: NodeJS.WritableStream;
9
+ /** Defaults to process.stderr. */
10
+ stderr?: NodeJS.WritableStream;
11
+ /** Override cwd resolution (test injection). Falls back to event.cwd then process.cwd(). */
12
+ cwd?: string;
13
+ /** Per-call ledger timeout in ms. */
14
+ ledgerTimeoutMs?: number;
15
+ /** Inject a manifest (test). */
16
+ manifest?: Manifest;
17
+ /** Inject a fake ledger query (test). */
18
+ ledgerQuery?: (sessionId: string) => Promise<LedgerEntry[] | {
19
+ degraded: string;
20
+ }>;
21
+ }
22
+ export interface PackHookPostMergeGateResult {
23
+ exitCode: number;
24
+ blocked: boolean;
25
+ /** Diagnostic line emitted to stderr (always, even on allow). */
26
+ diagnostic: string;
27
+ }
28
+ export declare function runPackHookPostMergeGateCli(opts?: PackHookPostMergeGateOptions): Promise<PackHookPostMergeGateResult>;
@@ -0,0 +1,244 @@
1
+ // `harness pack hook post-merge-gate` — PreToolUse blocker for the
2
+ // `post-merge-gate` policy pack.
3
+ //
4
+ // Receives Claude Code's PreToolUse event JSON on stdin and emits a
5
+ // `{ decision: "block" }` envelope when a CURATED history-mutating Bash
6
+ // command is about to run on a branch whose current tip exactly matches
7
+ // a merged-tip fact the producer recorded (see hook-post-merge-gate-record.ts).
8
+ //
9
+ // Evaluation order — deliberate, and pinned by the self-lock test suite:
10
+ //
11
+ // 1. Parse stdin. Malformed JSON, a non-Bash tool, or an empty command
12
+ // all ALLOW immediately (nothing to classify).
13
+ // 2. Pause sentinel (operator kill-switch) — same convention as every
14
+ // other pack hook.
15
+ // 3. ESCAPE ALLOWLIST — checked BEFORE manifest load, pack lookup, or
16
+ // any ledger query. `git switch`/`checkout`/`pull`/`fetch`,
17
+ // `git branch -d`/`-D`, `git stash list`/`show`, and any
18
+ // `harness ...` command ALWAYS pass, unconditionally, with NO
19
+ // dependency on whether the manifest or the ledger are reachable.
20
+ // This is the recovery path the deny message itself recommends; it
21
+ // must never be starved by an unrelated failure (the incident class
22
+ // this guards against: a different gate once got blocked alongside
23
+ // its own recovery command, and only the operator could unstick it).
24
+ // 4. CURATED MUTATION MATCH — commands outside the curated list
25
+ // (git commit/add/push/merge/rebase/cherry-pick/revert/reset/
26
+ // stash pop|apply, gh pr create/merge) pass through untouched.
27
+ // 5. Manifest load, pack-enabled check, git-context resolution
28
+ // (detached HEAD / no-repo ALLOWS — nothing to compare), then the
29
+ // ledger query.
30
+ // 6. DENY only when the current branch tip exactly matches a recorded
31
+ // `post-merge-gate:merged:<repo>:<branch>:<sha>` fact.
32
+ //
33
+ // Fail posture: OPEN. A manifest-load failure or a degraded ledger both
34
+ // ALLOW (with a stderr warning) — the inverse of branch-protection's
35
+ // fail-closed contract. Without the ledger, "merged" and "not merged" are
36
+ // indistinguishable, and fail-closed here would block ordinary git
37
+ // history work on every branch whenever grounding-mcp hiccups. See
38
+ // post-merge-gate-runtime.ts's header for the full rationale.
39
+ import { isCuratedMutationCommand, isEscapeCommand, MERGED_TAG_PREFIX, mergedTagMatchKey, PACK_NAME, resolveDefaultBranchName, } from "../../policy-packs/builtin/post-merge-gate-runtime.js";
40
+ import { queryLedgerByTag } from "../../policies/index.js";
41
+ import { resolveGitContext } from "../../runtime/git-context.js";
42
+ import { renderAgentFacing } from "../../runtime/agent-facing.js";
43
+ import { POLICY_DECISION_TYPE } from "../../runtime/ledger-record.js";
44
+ import { checkHookPause, loadManifestOrInjected, parseConfigUx, readStdin } from "./hook-bootstrap.js";
45
+ const DEFAULT_BRANCH_PLACEHOLDER = "<default-branch>";
46
+ function bashCommandOf(toolInput) {
47
+ if (typeof toolInput !== "object" || toolInput === null)
48
+ return "";
49
+ const cmd = toolInput["command"];
50
+ return typeof cmd === "string" ? cmd : "";
51
+ }
52
+ function findGroundingMcp(manifest) {
53
+ return manifest.tools.mcp.find((m) => m.name === "grounding-mcp") ?? null;
54
+ }
55
+ function evaluateEntries(entries, matchKey) {
56
+ for (const e of entries) {
57
+ // Skip policy_decision audit rows — a past DENIED decision this very
58
+ // pack recorded would otherwise incidentally carry the match key in
59
+ // its serialized payload, falsely satisfying itself. Same two-tier
60
+ // filter branch-protection's blocker uses (type first, legacy
61
+ // content-prefix backstop second).
62
+ if (e.type === POLICY_DECISION_TYPE)
63
+ continue;
64
+ if (e.content.startsWith(`${POLICY_DECISION_TYPE}:`))
65
+ continue;
66
+ if (e.content.includes(matchKey)) {
67
+ return { merged: true, matchedContent: e.content, degraded: null };
68
+ }
69
+ }
70
+ return { merged: false, matchedContent: null, degraded: null };
71
+ }
72
+ async function probeLedger(manifest, sessionId, matchKey, opts) {
73
+ if (opts.ledgerQuery) {
74
+ const r = await opts.ledgerQuery(sessionId);
75
+ if ("degraded" in r) {
76
+ return { merged: false, matchedContent: null, degraded: r.degraded };
77
+ }
78
+ return evaluateEntries(r, matchKey);
79
+ }
80
+ if (!manifest) {
81
+ return { merged: false, matchedContent: null, degraded: "manifest unavailable" };
82
+ }
83
+ const server = findGroundingMcp(manifest);
84
+ if (!server) {
85
+ return { merged: false, matchedContent: null, degraded: "grounding-mcp not declared in manifest" };
86
+ }
87
+ const command = Array.isArray(server.command)
88
+ ? server.command
89
+ : server.command.trim().split(/\s+/);
90
+ const env = server.env ?? undefined;
91
+ const timeoutMs = opts.ledgerTimeoutMs ?? server.health?.timeout_ms ?? 5_000;
92
+ const result = await queryLedgerByTag({
93
+ mcpCommand: command,
94
+ ...(env && { mcpEnv: env }),
95
+ sessionId,
96
+ timeoutMs,
97
+ });
98
+ if (result.kind === "degraded") {
99
+ return { merged: false, matchedContent: null, degraded: result.reason };
100
+ }
101
+ return evaluateEntries(result.entries, matchKey);
102
+ }
103
+ function blockJson(toolName, command, branch, defaultBranch, matchedContent, ux, sessionId) {
104
+ let reasonText;
105
+ if (ux) {
106
+ reasonText = renderAgentFacing(ux, {
107
+ BRANCH: branch,
108
+ DEFAULT_BRANCH: defaultBranch,
109
+ TOOL_NAME: toolName,
110
+ SESSION_ID: sessionId,
111
+ });
112
+ }
113
+ else {
114
+ reasonText =
115
+ `post-merge-gate: refusing ${toolName} ("${command}") on branch "${branch}" — ` +
116
+ `its current tip was already merged (recorded: ${matchedContent}).\n` +
117
+ `Further history-mutating commands on an already-merged branch usually mean stale local state.\n` +
118
+ `\n` +
119
+ `To proceed:\n` +
120
+ ` git switch ${defaultBranch}\n` +
121
+ ` git pull --ff-only\n` +
122
+ ` git branch -d ${branch} # optional cleanup\n` +
123
+ `\n` +
124
+ `If you have NEW work to continue on this branch (not leftover local state): make a commit first —` +
125
+ ` this gate only fires while the branch tip still exactly matches the merged commit, so a new commit` +
126
+ ` moves the tip and this check no longer applies.\n` +
127
+ `\n` +
128
+ `Escape hatches (always allowed, independent of this gate): git switch/checkout, git pull/fetch,` +
129
+ ` git branch -d/-D, git stash list/show, and any \`harness ...\` command.`;
130
+ }
131
+ return JSON.stringify({
132
+ decision: "block",
133
+ reason: reasonText,
134
+ hookSpecificOutput: {
135
+ hookEventName: "PreToolUse",
136
+ permissionDecision: "deny",
137
+ permissionDecisionReason: reasonText,
138
+ },
139
+ });
140
+ }
141
+ export async function runPackHookPostMergeGateCli(opts = {}) {
142
+ const stdin = opts.stdin ?? process.stdin;
143
+ const stdout = opts.stdout ?? process.stdout;
144
+ const stderr = opts.stderr ?? process.stderr;
145
+ const note = (msg) => {
146
+ stderr.write(`harness pack hook post-merge-gate: ${msg}\n`);
147
+ };
148
+ const raw = await readStdin(stdin);
149
+ let event = {};
150
+ try {
151
+ event = JSON.parse(raw.trim() || "{}");
152
+ }
153
+ catch {
154
+ const diagnostic = "malformed event JSON, cannot classify; allowing";
155
+ note(diagnostic);
156
+ return { exitCode: 0, blocked: false, diagnostic };
157
+ }
158
+ // Pause sentinel — even this gate yields to an operator pause.
159
+ if (checkHookPause(PACK_NAME, stderr, opts).paused) {
160
+ const diagnostic = "harness paused; post-merge-gate allowing without evaluating.";
161
+ return { exitCode: 0, blocked: false, diagnostic };
162
+ }
163
+ const sessionId = (typeof event.session_id === "string" ? event.session_id : undefined) ??
164
+ process.env.CLAUDE_CODE_SESSION_ID ??
165
+ process.env.CLAUDE_SESSION_ID ??
166
+ "";
167
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "(unknown)";
168
+ const cwd = typeof opts.cwd === "string" && opts.cwd.length > 0
169
+ ? opts.cwd
170
+ : typeof event.cwd === "string" && event.cwd.length > 0
171
+ ? event.cwd
172
+ : process.cwd();
173
+ if (toolName !== "Bash") {
174
+ const diagnostic = `${toolName} is not Bash; allowing`;
175
+ note(diagnostic);
176
+ return { exitCode: 0, blocked: false, diagnostic };
177
+ }
178
+ const command = bashCommandOf(event.tool_input);
179
+ if (command === "") {
180
+ const diagnostic = "no resolvable command text; allowing";
181
+ note(diagnostic);
182
+ return { exitCode: 0, blocked: false, diagnostic };
183
+ }
184
+ // ESCAPE ALLOWLIST — checked FIRST, unconditionally, before manifest
185
+ // load, pack lookup, or any ledger query. See module header.
186
+ if (isEscapeCommand(command)) {
187
+ const diagnostic = `escape command matched; allowing regardless of ledger/manifest state`;
188
+ note(diagnostic);
189
+ return { exitCode: 0, blocked: false, diagnostic };
190
+ }
191
+ if (!isCuratedMutationCommand(command)) {
192
+ const diagnostic = `command is not in the curated v1 deny-scope; allowing`;
193
+ note(diagnostic);
194
+ return { exitCode: 0, blocked: false, diagnostic };
195
+ }
196
+ // Fail-open manifest load (opposite of branch-protection's fail-closed):
197
+ // without the manifest we cannot tell if the pack is even enabled, and
198
+ // this pack's whole posture is advisory, not a hard security boundary.
199
+ let manifest;
200
+ try {
201
+ ({ manifest } = loadManifestOrInjected(opts, opts.manifest));
202
+ }
203
+ catch (err) {
204
+ const diagnostic = `manifest load failed (${err.message}); post-merge-gate fails open, allowing`;
205
+ note(diagnostic);
206
+ return { exitCode: 0, blocked: false, diagnostic };
207
+ }
208
+ const pack = manifest.policy_packs.find((p) => p.name === PACK_NAME);
209
+ if (!pack) {
210
+ const diagnostic = `pack "${PACK_NAME}" not declared in manifest, allowing`;
211
+ note(diagnostic);
212
+ return { exitCode: 0, blocked: false, diagnostic };
213
+ }
214
+ if (!pack.enabled) {
215
+ const diagnostic = `pack "${PACK_NAME}" is enabled:false, allowing`;
216
+ note(diagnostic);
217
+ return { exitCode: 0, blocked: false, diagnostic };
218
+ }
219
+ const { repo, branch, sha } = resolveGitContext(cwd);
220
+ if (branch === "" || sha === "" || repo === "") {
221
+ const diagnostic = `cannot resolve git context for ${cwd} (detached HEAD, outside a git work tree, or unresolvable sha); allowing`;
222
+ note(diagnostic);
223
+ return { exitCode: 0, blocked: false, diagnostic };
224
+ }
225
+ const configUx = parseConfigUx(pack.config["ux"], stderr, "harness pack hook post-merge-gate");
226
+ const matchKey = mergedTagMatchKey(repo, branch, sha);
227
+ const check = await probeLedger(manifest, sessionId, matchKey, opts);
228
+ if (check.degraded !== null) {
229
+ const diagnostic = `ledger degraded (${check.degraded}); post-merge-gate fails open, allowing`;
230
+ note(diagnostic);
231
+ return { exitCode: 0, blocked: false, diagnostic };
232
+ }
233
+ if (!check.merged) {
234
+ const diagnostic = `no recorded ${MERGED_TAG_PREFIX} fact for ${repo}:${branch} at tip ${sha.slice(0, 7)}; allowing`;
235
+ note(diagnostic);
236
+ return { exitCode: 0, blocked: false, diagnostic };
237
+ }
238
+ const defaultBranch = resolveDefaultBranchName(cwd) ?? DEFAULT_BRANCH_PLACEHOLDER;
239
+ const diagnostic = `BLOCK — branch tip ${sha.slice(0, 7)} matches a recorded merged tip (${check.matchedContent ?? matchKey})`;
240
+ note(diagnostic);
241
+ stdout.write(`${blockJson(toolName, command, branch, defaultBranch, check.matchedContent ?? matchKey, configUx, sessionId)}\n`);
242
+ return { exitCode: 0, blocked: true, diagnostic };
243
+ }
244
+ //# sourceMappingURL=hook-post-merge-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-post-merge-gate.js","sourceRoot":"","sources":["../../../src/cli/pack/hook-post-merge-gate.ts"],"names":[],"mappings":"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"}
@@ -17,6 +17,40 @@
17
17
  // (covers `echo >`, `$SOLUTION_VERDICT_DIR` spellings, `tee`, `mv`/`cp`/
18
18
  // `ln`/`install`, `python3 -c '...path...'`, and `chmod`/`chattr` that
19
19
  // would loosen perms) — or whose shell cwd is inside the dir.
20
+ // - Bash `cd` whose RESOLVED target is inside the verdict dir. `cd` itself
21
+ // is provably read-only (task fb67b402 added it to the shared
22
+ // read-only-bash floor: it mutates only the invoking shell's own cwd,
23
+ // never the filesystem), so it no longer reaches the "not read-only"
24
+ // checks below. But stepping into the dir is the setup half of a
25
+ // two-call forge (`cd <dir>` then, in a LATER un-chained Bash call,
26
+ // a bare relative write): this pre-check keeps that first step blocked
27
+ // as defense-in-depth, ahead of (not instead of) the cwd-inside check
28
+ // below, which is what actually closes the later relative-write call
29
+ // once cwd has genuinely moved. A `cd` target that `path.resolve`
30
+ // cannot literally evaluate does NOT get `cd`'s read-only fast path
31
+ // either: it falls through to the same reference-based checks as any
32
+ // other non-read-only Bash. `CD_TARGET_UNRESOLVABLE_CHARS` documents
33
+ // exactly WHICH expansions this enumerates (quoting, `$`/env-var,
34
+ // `~`, glob, and `{...}` brace expansion) — that list is what this
35
+ // check catches, not a claim that every shell-expansion class is
36
+ // closed. Known-open residual, pre-existing (not introduced by this
37
+ // branch, since it applies to `bashReferencesVerdictDir`'s textual
38
+ // match on ANY Bash command, not just `cd`): a backslash-escaped
39
+ // spelling of the leaf, e.g. `cd <parent>/solution\-verdicts`, still
40
+ // navigates into the dir under bash (verified against bash 3.2.57)
41
+ // while `bashReferencesVerdictDir` returns false — the backslash
42
+ // breaks the literal-leaf substring match and no metacharacter here
43
+ // triggers the glob/brace fallback either. Closing it needs
44
+ // shell-unescaping in the textual check itself, which is more than
45
+ // this task absorbs; do not assume the class is closed.
46
+ // A SECOND residual of the same class, and on a macOS operator
47
+ // machine the more reachable of the two: case variance.
48
+ // `cd <parent>/SOLUTION-VERDICTS` navigates into the dir on a
49
+ // case-insensitive APFS volume, but `isInsideDir` compares with a
50
+ // case-sensitive `path.relative` and the textual check uses a
51
+ // case-sensitive `includes`, so both return false. Also allowed:
52
+ // a trailing backslash, `cd <DIR>\`. Both pre-existing; closing
53
+ // them needs case-folding in the textual check.
20
54
  //
21
55
  // Pure reads (`cat <dir>/x.json`) are allowed so the guard is not over-broad.
22
56
  //
@@ -30,7 +64,10 @@
30
64
  // write paths above. A path constructed at runtime inside an interpreter
31
65
  // with no textual reference is NOT caught; marker signing (a cross-repo
32
66
  // follow-up) closes content-authenticity against an unguarded write
33
- // primitive.
67
+ // primitive. The `cd`-target check added later (task fb67b402) has its own
68
+ // enumerated, not exhaustive, expansion list — see the bullet above and
69
+ // `CD_TARGET_UNRESOLVABLE_CHARS`'s docstring for the backslash-escaping
70
+ // residual specifically.
34
71
  import { bashReferencesVerdictDir, isInsideDir, PACK_NAME, verdictDir as resolveVerdictDir, } from "../../policy-packs/builtin/solution-acceptance-runtime.js";
35
72
  import { isReadOnlyBashCommand } from "../../runtime/read-only-bash.js";
36
73
  import { checkHookPause, readStdin } from "./hook-bootstrap.js";
@@ -60,6 +97,94 @@ function bashCommandOf(toolInput) {
60
97
  const cmd = toolInput["command"];
61
98
  return typeof cmd === "string" ? cmd : "";
62
99
  }
100
+ /**
101
+ * Extract `cd`'s destination argument from a Bash command string, or null
102
+ * when the command is not a `cd` invocation or has no statically resolvable
103
+ * destination. Whitespace-tokenized, same as `read-only-bash.ts`'s own
104
+ * classifier (this hook does not shell-parse either).
105
+ *
106
+ * Deliberately narrow:
107
+ * - Only fires when the FIRST token is exactly `cd`. `env cd x` or a
108
+ * chained `foo && cd x` do not match here (tokens[0] would be `env` /
109
+ * `foo`); those are unaffected by this pre-check and are still governed
110
+ * by the existing not-read-only + `bashReferencesVerdictDir` checks
111
+ * below, which already close the `cd <parent> && write <relative>`
112
+ * descent via the leaf-segment match.
113
+ * - `cd`'s own flags (`-L`, `-P`, `-e`, `-@`, and combinations) are
114
+ * skipped to find the first positional path argument.
115
+ * - `cd -` (switch to `$OLDPWD`) and a bare `cd` (goes to `$HOME`) have no
116
+ * destination this function can resolve without ambient shell state;
117
+ * both return null (not flagged), the same conservative stance as the
118
+ * rest of this module's enumerated-path checks.
119
+ * - A single layer of surrounding quotes (`"..."` or `'...'`) is stripped
120
+ * from the returned token before the caller does anything with it, so
121
+ * `cd "<path>"` and `cd <path>` are treated identically (both the
122
+ * resolved-path inside-dir check AND the unresolvable-chars check see
123
+ * the same unquoted value). Without this, a quoted literal that merely
124
+ * shares a text prefix with the verdict dir (`cd "<dir>-decoy"`) would
125
+ * route to the textual leaf match and be wrongly blocked, while the
126
+ * unquoted `cd <dir>-decoy` correctly passes via the resolved-path
127
+ * check — spelling-dependent behavior for what is the same destination.
128
+ */
129
+ function cdTargetArgument(command) {
130
+ const tokens = command.trim().split(/\s+/);
131
+ if (tokens[0] !== "cd")
132
+ return null;
133
+ for (let i = 1; i < tokens.length; i += 1) {
134
+ const t = tokens[i];
135
+ if (t === undefined)
136
+ break;
137
+ if (t === "-")
138
+ return null;
139
+ if (t.startsWith("-"))
140
+ continue;
141
+ return stripSurroundingQuotes(t);
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * Strip one layer of surrounding double or single quotes from `token`, or
147
+ * return it unchanged if it is not fully wrapped in a matching pair. Does
148
+ * NOT attempt any other shell-quoting semantics (no backslash-escape
149
+ * handling, no nested-quote handling) — this is a narrow normalization so a
150
+ * simple quoted literal reads the same as its unquoted form, not a shell
151
+ * parser.
152
+ */
153
+ function stripSurroundingQuotes(token) {
154
+ if (token.length >= 2 &&
155
+ ((token.startsWith('"') && token.endsWith('"')) ||
156
+ (token.startsWith("'") && token.endsWith("'")))) {
157
+ return token.slice(1, -1);
158
+ }
159
+ return token;
160
+ }
161
+ /**
162
+ * Characters/sequences that make a `cd` destination token something
163
+ * `path.resolve` cannot literally evaluate: quoting (`"`, `'` — the residual
164
+ * case after `stripSurroundingQuotes` has already removed a clean single
165
+ * layer, e.g. an unbalanced or embedded quote), env-var expansion (`$`),
166
+ * home-directory expansion (`~`), shell globbing (`*`, `?`, `[`), and brace
167
+ * expansion (`{`, `}`, `,`). `path.resolve` treats the raw token as a
168
+ * literal path segment, so `cd "$SOLUTION_VERDICT_DIR"`, `cd
169
+ * ~/.../solution-verdicts`, or `cd <parent>/{solution-verdicts,x}` all
170
+ * resolve to a nonsense path that is never "inside" the dir even though a
171
+ * real shell would expand each of them TO (or, for the brace form, INTO)
172
+ * the dir. A `cd` target containing any of these must therefore NOT take
173
+ * the read-only fast path in `evaluateWriteGuard`: control must fall
174
+ * through to the same `bashReferencesVerdictDir` text-reference check any
175
+ * other non-read-only Bash command goes through, which DOES recognize
176
+ * these spellings (the env-var token, the `agent-grounding/solution-verdicts`
177
+ * tail, and — after the matching widening in
178
+ * `solution-acceptance-runtime.ts`'s glob-metacharacter test — glob- and
179
+ * brace-obscured leaf words).
180
+ *
181
+ * This list is enumerated, not exhaustive: it is exactly the expansion
182
+ * classes this check has been reasoned about, not a claim that every way a
183
+ * shell can rewrite a `cd` argument is covered. A known-open residual
184
+ * (pre-existing, not introduced by this check) is backslash escaping — see
185
+ * the module header's "Known-open residual" note.
186
+ */
187
+ const CD_TARGET_UNRESOLVABLE_CHARS = /[$~*?[{},"']/;
63
188
  /**
64
189
  * Pure write-guard decision for a tool event. Exported for direct unit
65
190
  * testing of the full forge-attempt matrix without spinning up the CLI.
@@ -100,7 +225,28 @@ export function evaluateWriteGuard(toolName, toolInput, dir, cwd) {
100
225
  const command = bashCommandOf(toolInput);
101
226
  if (command === "")
102
227
  return { blocked: false, reason: "empty Bash command" };
103
- if (isReadOnlyBashCommand(command)) {
228
+ // Defense-in-depth, ahead of the read-only fast path below: `cd` itself
229
+ // cannot write, but `cd`ing into the verdict dir is the setup half of a
230
+ // two-call forge (see the module header). Only a RESOLVED-path check
231
+ // (via the shared `isInsideDir`, not a substring match) so a sibling
232
+ // that merely shares a text prefix with the dir (`solution-verdicts-decoy`,
233
+ // or a parent directory of it) is not wrongly flagged.
234
+ const cdTarget = cdTargetArgument(command);
235
+ if (cdTarget !== null && isInsideDir(cdTarget, dir, cwd)) {
236
+ return {
237
+ blocked: true,
238
+ reason: `cd targets the harness-protected solution-verdict dir (${dir}); stepping into it would set up a later un-chained relative write to forge the verdict marker`,
239
+ };
240
+ }
241
+ // A `cd` target containing one of the enumerated unresolvable
242
+ // expansions (see `CD_TARGET_UNRESOLVABLE_CHARS`'s docstring for the
243
+ // exact list and its non-exhaustive scope) must not take `cd`'s
244
+ // read-only fast path below: without this, `cd "$SOLUTION_VERDICT_DIR"`
245
+ // etc. would be waved through by `isReadOnlyBashCommand` (true for ANY
246
+ // `cd`, per the shared read-only-bash floor) before ever reaching the
247
+ // reference check that recognizes these exact spellings.
248
+ const cdTargetUnresolvable = cdTarget !== null && CD_TARGET_UNRESOLVABLE_CHARS.test(cdTarget);
249
+ if (!cdTargetUnresolvable && isReadOnlyBashCommand(command)) {
104
250
  return { blocked: false, reason: "read-only Bash command" };
105
251
  }
106
252
  if (isInsideDir(".", dir, cwd)) {
@@ -123,7 +269,7 @@ function blockJson(toolName, reason) {
123
269
  const text = `solution-acceptance write-guard: refusing ${toolName}. ${reason}.\n` +
124
270
  `The solution-acceptance verdict marker is derived by the producer from a ` +
125
271
  `real preflight run; hand-writing it would forge a green "done". ` +
126
- `Run \`mcp__agent-grounding__solution_evaluate({ id: "<task-id>" })\` instead, ` +
272
+ `Run \`mcp__grounding-mcp__solution_evaluate({ id: "<task-id>" })\` instead, ` +
127
273
  `which writes the marker for you.\n` +
128
274
  `Operator override: \`harness pause\`.`;
129
275
  return JSON.stringify({