@lannguyensi/harness 0.43.0 → 0.45.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 (237) hide show
  1. package/CHANGELOG.md +1248 -0
  2. package/dist/cli/adopt/derive.d.ts +16 -7
  3. package/dist/cli/adopt/derive.js +19 -2
  4. package/dist/cli/adopt/derive.js.map +1 -1
  5. package/dist/cli/adopt/index.js +18 -3
  6. package/dist/cli/adopt/index.js.map +1 -1
  7. package/dist/cli/apply/apply.d.ts +1 -0
  8. package/dist/cli/apply/apply.js +62 -6
  9. package/dist/cli/apply/apply.js.map +1 -1
  10. package/dist/cli/apply/generate-codex-config.js +5 -6
  11. package/dist/cli/apply/generate-codex-config.js.map +1 -1
  12. package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
  13. package/dist/cli/apply/generate-opencode-config.js +210 -0
  14. package/dist/cli/apply/generate-opencode-config.js.map +1 -0
  15. package/dist/cli/apply/generate-settings.d.ts +90 -32
  16. package/dist/cli/apply/generate-settings.js +133 -10
  17. package/dist/cli/apply/generate-settings.js.map +1 -1
  18. package/dist/cli/apply/index.d.ts +2 -1
  19. package/dist/cli/apply/index.js +2 -1
  20. package/dist/cli/apply/index.js.map +1 -1
  21. package/dist/cli/approve/stdin-report.d.ts +15 -0
  22. package/dist/cli/approve/stdin-report.js +1 -1
  23. package/dist/cli/approve/stdin-report.js.map +1 -1
  24. package/dist/cli/approve/understanding.d.ts +75 -0
  25. package/dist/cli/approve/understanding.js +150 -2
  26. package/dist/cli/approve/understanding.js.map +1 -1
  27. package/dist/cli/audit.d.ts +1 -1
  28. package/dist/cli/audit.js +3 -2
  29. package/dist/cli/audit.js.map +1 -1
  30. package/dist/cli/doctor/claude-mcp.d.ts +19 -1
  31. package/dist/cli/doctor/claude-mcp.js +36 -10
  32. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  33. package/dist/cli/doctor/codex.d.ts +3 -1
  34. package/dist/cli/doctor/codex.js +6 -9
  35. package/dist/cli/doctor/codex.js.map +1 -1
  36. package/dist/cli/doctor/format.js +94 -8
  37. package/dist/cli/doctor/format.js.map +1 -1
  38. package/dist/cli/doctor/index.d.ts +23 -0
  39. package/dist/cli/doctor/index.js +106 -2
  40. package/dist/cli/doctor/index.js.map +1 -1
  41. package/dist/cli/doctor/opencode.d.ts +24 -0
  42. package/dist/cli/doctor/opencode.js +107 -0
  43. package/dist/cli/doctor/opencode.js.map +1 -0
  44. package/dist/cli/doctor/target-checks.d.ts +11 -0
  45. package/dist/cli/doctor/target-checks.js +25 -0
  46. package/dist/cli/doctor/target-checks.js.map +1 -0
  47. package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
  48. package/dist/cli/doctor/toolchain-parity.js +154 -0
  49. package/dist/cli/doctor/toolchain-parity.js.map +1 -0
  50. package/dist/cli/doctor/types.d.ts +85 -1
  51. package/dist/cli/doctor/types.js +1 -1
  52. package/dist/cli/doctor/types.js.map +1 -1
  53. package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
  54. package/dist/cli/doctor/understanding-mode-env.js +83 -0
  55. package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
  56. package/dist/cli/dry-run.js +34 -13
  57. package/dist/cli/dry-run.js.map +1 -1
  58. package/dist/cli/explain.d.ts +1 -1
  59. package/dist/cli/explain.js +1 -1
  60. package/dist/cli/explain.js.map +1 -1
  61. package/dist/cli/index.js +62 -7
  62. package/dist/cli/index.js.map +1 -1
  63. package/dist/cli/init/composer.js +36 -13
  64. package/dist/cli/init/composer.js.map +1 -1
  65. package/dist/cli/init/interactive.d.ts +7 -4
  66. package/dist/cli/init/interactive.js +79 -27
  67. package/dist/cli/init/interactive.js.map +1 -1
  68. package/dist/cli/init/profiles.d.ts +2 -2
  69. package/dist/cli/init/profiles.js +32 -1
  70. package/dist/cli/init/profiles.js.map +1 -1
  71. package/dist/cli/init/templates.d.ts +2 -1
  72. package/dist/cli/init/templates.js +136 -24
  73. package/dist/cli/init/templates.js.map +1 -1
  74. package/dist/cli/pack/approve-escape.js +69 -8
  75. package/dist/cli/pack/approve-escape.js.map +1 -1
  76. package/dist/cli/pack/hook-branch-protection.js +1 -1
  77. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  78. package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
  79. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  80. package/dist/cli/pack/hook-post-merge-gate.js +44 -26
  81. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  82. package/dist/cli/pack/hook-pre-tool-use.js +91 -4
  83. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  84. package/dist/cli/pack/hook-solution-acceptance.js +19 -2
  85. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  86. package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
  87. package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
  88. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  89. package/dist/cli/pause/index.js +1 -1
  90. package/dist/cli/pause/index.js.map +1 -1
  91. package/dist/cli/policy/intercept.d.ts +148 -0
  92. package/dist/cli/policy/intercept.js +538 -79
  93. package/dist/cli/policy/intercept.js.map +1 -1
  94. package/dist/cli/session-start/index.d.ts +12 -3
  95. package/dist/cli/session-start/index.js +67 -11
  96. package/dist/cli/session-start/index.js.map +1 -1
  97. package/dist/cli/session-start/stale-base-check.d.ts +71 -0
  98. package/dist/cli/session-start/stale-base-check.js +379 -0
  99. package/dist/cli/session-start/stale-base-check.js.map +1 -0
  100. package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
  101. package/dist/cli/session-start/toolchain-parity.js +244 -36
  102. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  103. package/dist/cli/smoke/assertions.d.ts +9 -2
  104. package/dist/cli/smoke/assertions.js +9 -2
  105. package/dist/cli/smoke/assertions.js.map +1 -1
  106. package/dist/cli/uninstall/index.js +18 -2
  107. package/dist/cli/uninstall/index.js.map +1 -1
  108. package/dist/cli/validate/checks.d.ts +3 -1
  109. package/dist/cli/validate/checks.js +298 -3
  110. package/dist/cli/validate/checks.js.map +1 -1
  111. package/dist/io/claude-mcp.d.ts +122 -1
  112. package/dist/io/claude-mcp.js +214 -12
  113. package/dist/io/claude-mcp.js.map +1 -1
  114. package/dist/io/duration.js.map +1 -0
  115. package/dist/io/expand-home.js.map +1 -0
  116. package/dist/io/extract.js.map +1 -0
  117. package/dist/io/harness-lock.js +1 -1
  118. package/dist/io/harness-lock.js.map +1 -1
  119. package/dist/{runtime → io}/ledger-record.d.ts +1 -1
  120. package/dist/io/ledger-record.js.map +1 -0
  121. package/dist/policies/index.d.ts +2 -2
  122. package/dist/policies/index.js +2 -2
  123. package/dist/policies/index.js.map +1 -1
  124. package/dist/policies/ledger-client.js +10 -6
  125. package/dist/policies/ledger-client.js.map +1 -1
  126. package/dist/policies/producers.js +1 -1
  127. package/dist/policies/producers.js.map +1 -1
  128. package/dist/policies/requires.js +2 -2
  129. package/dist/policies/requires.js.map +1 -1
  130. package/dist/policy-packs/builtin/branch-protection.js +28 -3
  131. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  132. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
  133. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
  134. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  135. package/dist/policy-packs/builtin/post-merge-gate.js +84 -31
  136. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  137. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +152 -8
  138. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +260 -13
  139. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  140. package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
  141. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
  142. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
  143. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
  144. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
  145. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
  146. package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
  147. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
  148. package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
  149. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
  150. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
  151. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
  152. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
  153. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
  154. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
  155. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
  156. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
  157. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
  158. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
  159. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
  160. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
  161. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
  162. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
  163. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
  164. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
  165. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
  166. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
  167. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
  168. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  169. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
  170. package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
  171. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  172. package/dist/policy-packs/registry.d.ts +11 -0
  173. package/dist/policy-packs/registry.js +1 -1
  174. package/dist/policy-packs/registry.js.map +1 -1
  175. package/dist/policy-packs/runtime.d.ts +1 -1
  176. package/dist/policy-packs/runtime.js +10 -1
  177. package/dist/policy-packs/runtime.js.map +1 -1
  178. package/dist/runtime/agent-facing.js +1 -1
  179. package/dist/runtime/agent-facing.js.map +1 -1
  180. package/dist/runtime/bash-match-facts.d.ts +9 -7
  181. package/dist/runtime/bash-match-facts.js +9 -7
  182. package/dist/runtime/bash-match-facts.js.map +1 -1
  183. package/dist/runtime/bash-match-registry.d.ts +75 -38
  184. package/dist/runtime/bash-match-registry.js +279 -232
  185. package/dist/runtime/bash-match-registry.js.map +1 -1
  186. package/dist/runtime/bash-prefix-parse.d.ts +7 -0
  187. package/dist/runtime/bash-prefix-parse.js +205 -17
  188. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  189. package/dist/runtime/command-normalize.d.ts +429 -4
  190. package/dist/runtime/command-normalize.js +1146 -67
  191. package/dist/runtime/command-normalize.js.map +1 -1
  192. package/dist/runtime/hermetic-spawn-guard.js +6 -0
  193. package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
  194. package/dist/runtime/index.d.ts +2 -2
  195. package/dist/runtime/index.js +2 -2
  196. package/dist/runtime/index.js.map +1 -1
  197. package/dist/runtime/intercept.d.ts +247 -4
  198. package/dist/runtime/intercept.js +553 -61
  199. package/dist/runtime/intercept.js.map +1 -1
  200. package/dist/runtime/ledger-add.js +1 -1
  201. package/dist/runtime/ledger-add.js.map +1 -1
  202. package/dist/runtime/read-only-bash.js +356 -26
  203. package/dist/runtime/read-only-bash.js.map +1 -1
  204. package/dist/runtime/shell-word.d.ts +21 -0
  205. package/dist/runtime/shell-word.js +242 -0
  206. package/dist/runtime/shell-word.js.map +1 -0
  207. package/dist/schema/doctor.d.ts +25 -0
  208. package/dist/schema/doctor.js +23 -0
  209. package/dist/schema/doctor.js.map +1 -0
  210. package/dist/schema/extract.js +1 -1
  211. package/dist/schema/extract.js.map +1 -1
  212. package/dist/schema/index.d.ts +76 -0
  213. package/dist/schema/index.js +9 -0
  214. package/dist/schema/index.js.map +1 -1
  215. package/dist/schema/requires.js +2 -2
  216. package/dist/schema/requires.js.map +1 -1
  217. package/dist/schema/risk.d.ts +7 -0
  218. package/dist/schema/risk.js +23 -0
  219. package/dist/schema/risk.js.map +1 -1
  220. package/dist/schema/stale-base-check.d.ts +18 -0
  221. package/dist/schema/stale-base-check.js +37 -0
  222. package/dist/schema/stale-base-check.js.map +1 -0
  223. package/dist/schema/toolchain-parity.d.ts +3 -0
  224. package/dist/schema/toolchain-parity.js +13 -0
  225. package/dist/schema/toolchain-parity.js.map +1 -1
  226. package/package.json +5 -2
  227. package/dist/policies/duration.js.map +0 -1
  228. package/dist/policies/extract.js.map +0 -1
  229. package/dist/runtime/expand-home.js.map +0 -1
  230. package/dist/runtime/ledger-record.js.map +0 -1
  231. /package/dist/{policies → io}/duration.d.ts +0 -0
  232. /package/dist/{policies → io}/duration.js +0 -0
  233. /package/dist/{runtime → io}/expand-home.d.ts +0 -0
  234. /package/dist/{runtime → io}/expand-home.js +0 -0
  235. /package/dist/{policies → io}/extract.d.ts +0 -0
  236. /package/dist/{policies → io}/extract.js +0 -0
  237. /package/dist/{runtime → io}/ledger-record.js +0 -0
@@ -7,10 +7,11 @@ import { openLedgerSession, } from "../../policies/index.js";
7
7
  import * as fs from "node:fs";
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
- import { intercept, recordPolicyDecisionOnSession, resolveGitContext, resolveKubeContext, } from "../../runtime/index.js";
10
+ import { buildActionEnvelope, intercept, isBlockingDecision, recordPolicyDecisionOnSession, resolveEnvironment, resolveGitContext, resolveKubeContext, sanitizeEnvelopeReason, } from "../../runtime/index.js";
11
11
  import { resolveGeneratedDir, writePendingApproval } from "../../runtime/pending-approval.js";
12
12
  import { parseBashPrefix } from "../../runtime/bash-prefix-parse.js";
13
- import { MAX_NORMALIZE_LENGTH, normalizeCommand, } from "../../runtime/command-normalize.js";
13
+ import { MAX_NORMALIZE_LENGTH, normalizeCommand, normalizeCommandAmpAware, normalizeCommandQuoteAware, segmentViewOf, } from "../../runtime/command-normalize.js";
14
+ import { extractShellCommand, SHELL_ALIASES } from "../../runtime/tool-name-aliases.js";
14
15
  import { loadManifest } from "../loader.js";
15
16
  import { checkPauseFromLoader } from "../pause-check.js";
16
17
  async function readStdin(stream) {
@@ -51,7 +52,19 @@ function hookSuffix(hookName) {
51
52
  * policy name so concurrent fires (rare but possible) stay readable.
52
53
  */
53
54
  function formatDecisionDiagnostic(decision, hookName) {
54
- const header = `harness policy intercept${hookSuffix(hookName)}: ${decision.policyName}: ${decision.outcome}${decision.outcome === "warn-degraded" ? " (ledger unreachable)" : ""}`;
55
+ // The deny-degraded suffix names the fail_open opt-out ON PURPOSE and
56
+ // ONLY here: stderr is the operator's surface. The agent-facing deny
57
+ // envelope deliberately omits it (see the deny-degraded branch in
58
+ // `runtime/intercept.ts` — a deny that includes its own disable recipe
59
+ // is not a gate).
60
+ // Both degraded arms are cause-neutral: the degraded family has five
61
+ // causes and only one of them is the ledger; the true cause follows
62
+ // on the block's own `reason:` line (review 2026-08-08, rounds 3+5).
63
+ const header = `harness policy intercept${hookSuffix(hookName)}: ${decision.policyName}: ${decision.outcome}${decision.outcome === "warn-degraded"
64
+ ? " (evidence could not be evaluated; non-blocking per warn tier or fail_open opt-out)"
65
+ : decision.outcome === "deny-degraded"
66
+ ? " (evidence could not be evaluated; failing closed per enforcement tier; operator opt-out: risk.degraded_fail_posture: fail_open)"
67
+ : ""}`;
55
68
  const lines = [header];
56
69
  lines.push(` ledger_tag: ${decision.ledgerTag}`);
57
70
  if (decision.requiresEval !== undefined) {
@@ -92,11 +105,13 @@ function readBashCommand(input) {
92
105
  const cmd = input.command;
93
106
  return typeof cmd === "string" && cmd.length > 0 ? cmd : null;
94
107
  }
108
+ // Derived from `SHELL_ALIASES` rather than hand-copying its members (fix
109
+ // round 2): `tool-name-aliases.ts`'s own header warns against the copy,
110
+ // and since fix round 1 the precompute-matches-matcher argument above
111
+ // depends on this set and the alias expansion agreeing. Deriving it means
112
+ // they cannot drift apart.
95
113
  function isCodexShellTool(toolName) {
96
- return (toolName === "Bash" ||
97
- toolName === "shell" ||
98
- toolName === "exec_command" ||
99
- toolName === "functions.exec_command");
114
+ return typeof toolName === "string" && SHELL_ALIASES.includes(toolName);
100
115
  }
101
116
  function extractPerCallCwd(input) {
102
117
  if (!input || typeof input !== "object" || Array.isArray(input))
@@ -138,6 +153,48 @@ function resolvePolicyCwd(event, codexCommandCwd) {
138
153
  }
139
154
  return process.cwd();
140
155
  }
156
+ // Mirrors `ENV_PRECEDENCE` in `../../runtime/environment-resolver.ts`
157
+ // (most-dangerous-first). Kept as a local, small, duplicate table
158
+ // instead of importing that module's private const, so this file stays
159
+ // a read-only consumer of `resolveEnvironment`'s public API rather than
160
+ // reaching into its internals. `unknown` is deliberately ranked lowest:
161
+ // resolving to no signal at all must never look like an "upgrade" over
162
+ // a branch that DID fire a resolver.
163
+ const ENV_RANK = {
164
+ production: 0,
165
+ staging: 1,
166
+ dev: 2,
167
+ local: 3,
168
+ unknown: 4,
169
+ };
170
+ /**
171
+ * Branch-switch, upgrade-only merge (task 341e024b): a leading `git
172
+ * switch <branch>` / `git checkout <branch>` names the branch the REST
173
+ * of the command actually runs against, the same way a leading `cd
174
+ * <path>` names a different working directory (see `resolverGit`'s own
175
+ * comment at the call site). Unlike that `cd` merge — which fully
176
+ * REPLACES the git context and can move the resolved environment in
177
+ * EITHER direction (G5, pre-existing, out of scope for this task) —
178
+ * this merge is deliberately asymmetric: it only ever pushes the
179
+ * resolved environment to something MORE dangerous, never less.
180
+ *
181
+ * Both the base git context and the switch-target candidate (same repo
182
+ * / sha, only `branch` differs) are run through the SAME
183
+ * `resolveEnvironment` call with IDENTICAL env / kube inputs, so any
184
+ * difference in the result is attributable to the branch alone. The
185
+ * more dangerous of the two (`ENV_RANK` order) wins; a switch AWAY from
186
+ * a production branch never downgrades — when the candidate resolves to
187
+ * something equally or less dangerous, `baseGit` is returned unchanged.
188
+ */
189
+ function applyBranchSwitchUpgrade(event, manifest, cwd, baseGit, branchTarget, inputs, user, host, now) {
190
+ if (branchTarget === null || branchTarget === baseGit.branch)
191
+ return baseGit;
192
+ const candidateGit = { ...baseGit, branch: branchTarget };
193
+ const effectiveNow = now ?? new Date();
194
+ const baseResolution = resolveEnvironment(buildActionEnvelope(event, { cwd, git: baseGit, user, host, now: effectiveNow }), manifest.environments.resolvers, inputs);
195
+ const candidateResolution = resolveEnvironment(buildActionEnvelope(event, { cwd, git: candidateGit, user, host, now: effectiveNow }), manifest.environments.resolvers, inputs);
196
+ return ENV_RANK[candidateResolution.name] < ENV_RANK[baseResolution.name] ? candidateGit : baseGit;
197
+ }
141
198
  /**
142
199
  * Real grounding-mcp-backed ledger client. Exported for testing: the
143
200
  * `record` adapter's failure-surfacing path is exercised against a
@@ -162,6 +219,38 @@ export function realLedgerClient(server, opts) {
162
219
  const stderr = opts.stderr ?? process.stderr;
163
220
  let session = null;
164
221
  const summaryCache = new Map();
222
+ // One fresh-session retry for audit writes per client instance,
223
+ // reserved for `deny-degraded` decisions (task f1aea826, AC4; review
224
+ // 2026-08-08 tightened both the reservation and the budget). The
225
+ // pooled session carries a session-wide timeout latch: a single slow
226
+ // QUERY (the very condition that produces a blocking `deny-degraded`)
227
+ // short-circuits every later call on the same session, including the
228
+ // audit write for that decision — so the one row that most needs to
229
+ // land was guaranteed to be lost. A fresh subprocess lands it whenever
230
+ // grounding-mcp itself is healthy (the measured 2026-08-06 case: only
231
+ // the artificial 1ms query budget latched; follow-up calls answered
232
+ // fine).
233
+ //
234
+ // Bounds, stated precisely because the record runs on the deny path
235
+ // BEFORE the envelope is emitted and any stall risks the outer
236
+ // hook-timeout-is-allow contract: the retry is (a) reserved for
237
+ // decisions whose outcome is `deny-degraded` — an allow/warn row
238
+ // failing first must not consume it — (b) taken at most once per
239
+ // client instance, and (c) run on a session whose OWN timeout is
240
+ // `auditRetryTimeoutMs(timeoutMs)` = max(250ms, timeoutMs/4). Its two
241
+ // calls (initialize + ledger_add) therefore add at most
242
+ // 2 * max(250ms, timeoutMs/4) on top of the query's timeoutMs — that
243
+ // is <=timeoutMs/2 (total <=1.5x) once timeoutMs >= 1s, while below
244
+ // 1s the 250ms floor dominates and the ADD is bounded at 500ms
245
+ // absolute (review 2026-08-08 round 2: at the audit's 1-100ms probe
246
+ // settings that is a multiple of the query budget, but a trivial
247
+ // absolute cost). The fresh session replaces the latched one, so
248
+ // later audit writes in the same invocation inherit the working
249
+ // transport (and its tighter budget). If the retry also fails, the
250
+ // loud-stderr contract stands (audit degradation never blocks,
251
+ // docs/okf matrix).
252
+ const retryTimeoutMs = auditRetryTimeoutMs(timeoutMs);
253
+ let recordRetryUsed = false;
165
254
  const getSession = () => (session ??= openLedgerSession({
166
255
  mcpCommand: command,
167
256
  ...(env && { mcpEnv: env }),
@@ -172,9 +261,12 @@ export function realLedgerClient(server, opts) {
172
261
  // Cache the Promise (not the value) so concurrent queries for the
173
262
  // same session dedupe onto one in-flight round-trip. A degraded
174
263
  // result is cached too — deliberately: a first-query timeout
175
- // degrades ALL K policies to warn-degraded (fail-open, matching the
176
- // documented ledger-unreachable contract) instead of retrying per
177
- // policy, which would reintroduce the fan-out. Staleness: our own
264
+ // degrades ALL K policies off the one cached round-trip instead of
265
+ // retrying per policy, which would reintroduce the fan-out. (What
266
+ // a degraded evaluation MEANS is tier-dependent since task
267
+ // f1aea826: warn policies stay non-blocking `warn-degraded`,
268
+ // block/require_approval fail closed as `deny-degraded`.)
269
+ // Staleness: our own
178
270
  // `record` rows are filtered out by the evaluator by design; an
179
271
  // EXTERNAL writer landing evidence mid-loop would make a later
180
272
  // policy deny where a fresh query would allow — a fail-closed,
@@ -188,7 +280,24 @@ export function realLedgerClient(server, opts) {
188
280
  return cached;
189
281
  },
190
282
  async record(decision, sessionId) {
191
- const result = await recordPolicyDecisionOnSession(getSession(), decision, sessionId);
283
+ let result = await recordPolicyDecisionOnSession(getSession(), decision, sessionId);
284
+ if (!result.ok &&
285
+ decision.outcome === "deny-degraded" &&
286
+ !recordRetryUsed) {
287
+ // Latched-session recovery (bounds documented at retryTimeoutMs
288
+ // above): drop the pooled session and retry ONCE over a fresh
289
+ // subprocess with the tighter retry budget. Queries are
290
+ // unaffected — their per-session summary promise is already
291
+ // cached by the time any record runs.
292
+ recordRetryUsed = true;
293
+ session?.dispose();
294
+ session = openLedgerSession({
295
+ mcpCommand: command,
296
+ ...(env && { mcpEnv: env }),
297
+ timeoutMs: retryTimeoutMs,
298
+ });
299
+ result = await recordPolicyDecisionOnSession(session, decision, sessionId);
300
+ }
192
301
  // A failed audit-write must not block the tool — the decision is
193
302
  // still applied — but it must not be silent either: an unrecorded
194
303
  // decision is invisible to `harness audit` / `explain --trace`.
@@ -203,13 +312,178 @@ export function realLedgerClient(server, opts) {
203
312
  },
204
313
  };
205
314
  }
206
- function degradedLedgerClient(reason) {
315
+ /**
316
+ * Per-call budget of the audit-retry session (task f1aea826, review
317
+ * round 2): a quarter of the ledger timeout with a 250ms floor (a fresh
318
+ * node subprocess must cold-start + initialize within it). Exported as a
319
+ * pure function so the FORMULA itself is pinned by a unit test — the
320
+ * round-1 finding this bounds was precisely that an unbounded retry
321
+ * reused the full budget, and nothing would go red if that regressed.
322
+ */
323
+ export function auditRetryTimeoutMs(timeoutMs) {
324
+ return Math.max(250, Math.floor(timeoutMs / 4));
325
+ }
326
+ /**
327
+ * Recognise a hook `command` string as an invocation of `harness policy
328
+ * intercept`'s subcommand, regardless of how the leading interpreter /
329
+ * binary token is spelled: a bare `harness policy intercept`, a node-path
330
+ * form (`node /abs/path/dist/cli/index.js policy intercept`, e.g. a local
331
+ * dev build), an `npx` form (`npx harness policy intercept`), a leading
332
+ * inline env-var assignment (`FOO=bar harness policy intercept` — the
333
+ * same shape `understanding-before-execution.ts`'s `wrap()` helper
334
+ * prefixes pack-contributed commands with), or a trailing flag
335
+ * (`harness policy intercept --hook <name>`, the tag
336
+ * `generate-codex-config.ts#commandWithHookTag` appends).
337
+ *
338
+ * Every OTHER call site that needs to recognise this subcommand in this
339
+ * codebase today (`generate-settings.ts#hookTimeoutSeconds`,
340
+ * `generate-codex-config.ts#commandWithHookTag`) still compares
341
+ * `command.trim() === "harness policy intercept"` VERBATIM — deliberately
342
+ * left unchanged (task d20a7e0c scope: the hook-timeout projection
343
+ * contract those two own is out of scope). For THEM a missed match only
344
+ * changes a floor/flag on a command harness itself emits byte-for-byte,
345
+ * so the single canonical spelling is enough. This matcher exists for a
346
+ * caller (the validate/doctor hook-budget-vs-ledger-timeout guard, task
347
+ * d20a7e0c) where under-recognition is a false NEGATIVE on a security
348
+ * check against an OPERATOR-authored `manifest.hooks[]` entry, which is
349
+ * under no obligation to spell the command exactly the way harness's own
350
+ * generators do.
351
+ *
352
+ * Deliberately narrow, not a shell parser: matches the two literal words
353
+ * `policy` and `intercept` as adjacent, whitespace-separated tokens,
354
+ * preceded by start-of-string / whitespace / a path separator (so
355
+ * `mypolicy intercept` inside one longer token cannot match) and followed
356
+ * by whitespace, end-of-string, or a semicolon/quote character (`;`, `"`,
357
+ * `'`) immediately abutting the word — the shape a chained shell command
358
+ * (`harness policy intercept; echo done`) or an embedded `sh -c
359
+ * "harness policy intercept"` wrapper produces, where nothing whitespace
360
+ * separates `intercept` from the next token (review 2026-08-09, fix round
361
+ * 1, finding 3). The quote arm accepts more than it strictly should: a
362
+ * quote glued directly onto the word (`intercept'or'`) also matches, even
363
+ * though a shell concatenates that into a different, longer identifier
364
+ * (`interceptor`). This is a deliberate, harmless OVER-flag — at worst a
365
+ * spurious validate/doctor diagnostic on an exotic command, never a missed
366
+ * real hook — accepted because the same quote boundary is load-bearing for
367
+ * the legitimate `sh -c "...intercept"` wrapper above. Still not
368
+ * exhaustive: a trailing `)`, `|`, `&`, or
369
+ * backtick immediately abutting the word (e.g. `$(harness policy
370
+ * intercept)`) is a KNOWN, deliberately-unclosed gap — widening further
371
+ * risks matching inside an unrelated longer token with no whitespace
372
+ * boundary at all, and the shapes above already cover the realistic
373
+ * manifest-authoring patterns this matcher exists for. A false negative
374
+ * here degrades to the pre-existing verbatim behaviour (the hook is
375
+ * simply not classified as ledger-consulting, so the margin check has
376
+ * nothing to say about it) — not a runtime bypass; the dispatch this
377
+ * check advises about is unaffected either way.
378
+ */
379
+ export function isPolicyInterceptCommand(command) {
380
+ return /(?:^|[\s/\\])policy\s+intercept(?:\s|$|[;"'])/.test(command);
381
+ }
382
+ /**
383
+ * Minimum `budget_ms` a blocking, ledger-consulting hook needs to clear
384
+ * the worst-case wall time `realLedgerClient` (below) can spend before it
385
+ * writes its own fail-closed decision — derived from that function's OWN
386
+ * two round-trip shapes, not a measured-then-eyeballed constant. Both
387
+ * halves are pinned against the real client in
388
+ * tests/e2e/policy-intercept.test.ts ("hook-budget-ledger-margin
389
+ * derivation (task d20a7e0c)").
390
+ *
391
+ * SCOPE, stated precisely (review 2026-08-09, fix round 1): this is the
392
+ * worst case on the PURE-TIMEOUT HANG SHAPE — the shape where the
393
+ * `query()` call that degrades does so by tripping `LedgerSession`'s own
394
+ * session-wide `timedOut` latch (src/policies/ledger-client.ts), which is
395
+ * what lets the retry sequence below start from an INSTANT first
396
+ * `record()` attempt. A query() that instead degrades via a NON-timeout
397
+ * path — `ledger_summary` coming back a genuine JSON-RPC error, an
398
+ * unparseable payload, or a payload missing the `entries` shape
399
+ * (`querySummary`'s non-timeout `degraded` branches in
400
+ * src/policies/ledger-client.ts) — never sets that latch, so it falls
401
+ * OUTSIDE this bound. See the KNOWN RESIDUAL paragraph at the end of this
402
+ * comment; do not read this function, or the `checkHookBudgetLedgerMargin`
403
+ * diagnostic it backs, as a blanket worst-case guarantee.
404
+ *
405
+ * `T` = the ledger's own per-call budget (`health.timeout_ms`, defaulted
406
+ * to 5000 exactly like `realLedgerClient` itself). `R` =
407
+ * `auditRetryTimeoutMs(T)`, the audit-retry session's own per-call budget.
408
+ *
409
+ * 1. query() — up to 2×T. `LedgerSession.querySummary`
410
+ * (src/policies/ledger-client.ts) performs `ensureInitialized()`
411
+ * (the `initialize` handshake, its own T-bounded wait) and, only
412
+ * once THAT resolves, the actual `ledger_summary` `tools/call` (a
413
+ * SEPARATE T-bounded wait). A ledger that is merely SLOW rather than
414
+ * hard-down (the threat class docs/risk-gate.md and this file's own
415
+ * `deny-degraded` diagnostics already name, distinct from an
416
+ * instantly-refusing one) can answer `initialize` just under T and
417
+ * still time out on the following `ledger_summary` — the case a
418
+ * naive "1×T" assumption undercounts.
419
+ *
420
+ * 2. the deny-degraded audit retry — up to 3×R. On the pure-timeout
421
+ * hang shape, the pooled session's `timedOut` latch (tripped by the
422
+ * query()-side timeout itself) makes the FIRST `record()` attempt on
423
+ * it instant (see `realLedgerClient`'s own doc comment above). The
424
+ * ONE reserved retry then opens a FRESH session budgeted at R and
425
+ * calls `recordPolicyDecisionOnSession` (src/runtime/ledger-
426
+ * record.ts), which can spend up to three independent R-bounded
427
+ * round-trips before it can report success or give up: the fresh
428
+ * session's own `initialize` handshake, the first `ledger_add`
429
+ * attempt at `type: "policy_decision"`, and — only reached when that
430
+ * attempt comes back a genuine JSON-RPC error rather than a
431
+ * transport timeout (an older/incompatible grounding-mcp rejecting
432
+ * the newer type) — the legacy `type: "fact"` fallback `ledger_add`.
433
+ *
434
+ * query() must complete (or degrade) before any policy decision — and so
435
+ * any record() — is computed (`intercept()`'s per-policy loop in
436
+ * src/runtime/intercept.ts awaits `evaluateOnePolicy`, which calls
437
+ * `ledger.query`, before it calls `ledger.record`), and the retry only
438
+ * starts after the first record() attempt on the pooled session already
439
+ * failed, so the two stages are SEQUENTIAL within one hook invocation:
440
+ * worst case ON THE PURE-TIMEOUT HANG SHAPE = 2T + 3R.
441
+ *
442
+ * KNOWN RESIDUAL (review 2026-08-09, fix round 1): when `query()` instead
443
+ * degrades via one of the NON-timeout branches named in the SCOPE
444
+ * paragraph above, the pooled session's `timedOut` latch is never set, so
445
+ * `record()`'s own first attempt is a REAL round-trip too — up to T for
446
+ * the `type: "policy_decision"` `ledger_add` attempt, plus another up to
447
+ * T for the legacy `type: "fact"` fallback if that attempt is itself
448
+ * rejected non-timeout — BEFORE the 3R retry sequence even starts. Worst
449
+ * case on this "error-then-hang" shape is therefore up to 4T + 3R, not
450
+ * 2T + 3R: 23750ms at the shipped T=5000 default, against this
451
+ * function's 13750ms. This formula is DELIBERATELY NOT raised to
452
+ * 4T + 3R: doing so would fail `checkHookBudgetLedgerMargin` against the
453
+ * shipped, reviewer-confirmed-clean fresh-init budget of 15000ms
454
+ * (src/cli/init/templates.ts, task 7bf47554/#414 — out of scope here) at
455
+ * the same T=5000 default, since 15000 < 23750. The guard this function
456
+ * backs GUARANTEES delivery of the fail-closed verdict on the
457
+ * pure-timeout hang shape, but NOT on this error-then-hang shape.
458
+ * Unlike an earlier draft of this note, this residual is NOT confined to
459
+ * one runtime projection: Claude Code and Codex both convert `budget_ms`
460
+ * into the outer hook-kill seconds via the identical `hookTimeoutSeconds`
461
+ * helper (src/cli/apply/generate-settings.ts, shared by
462
+ * generate-codex-config.ts) since task 7bf47554/#414, so a manifest at
463
+ * the shipped defaults is equally exposed on both — this was checked
464
+ * against both generators' current source, not assumed.
465
+ */
466
+ export function requiredHookBudgetMs(ledgerTimeoutMs) {
467
+ return 2 * ledgerTimeoutMs + 3 * auditRetryTimeoutMs(ledgerTimeoutMs);
468
+ }
469
+ function degradedLedgerClient(reason, stderr, hookName) {
207
470
  return {
208
471
  async query() {
209
472
  return { kind: "degraded", reason };
210
473
  },
211
- async record() {
212
- /* no-op when ledger is unavailable */
474
+ async record(decision) {
475
+ // No transport exists, so no audit row can ever land. Stay silent
476
+ // for non-blocking rows (a manifest without grounding-mcp would
477
+ // otherwise emit one line per policy per tool event), but a
478
+ // BLOCKING decision with no audit row must not also be invisible
479
+ // (review 2026-08-08: previously a deny-degraded produced neither
480
+ // a row nor an audit-write-failed line on this path). Blocking-ness
481
+ // is the runtime's own isBlockingDecision — round 2 caught a third
482
+ // hand-rolled copy of that predicate here.
483
+ if (isBlockingDecision(decision)) {
484
+ stderr.write(`harness policy intercept${hookSuffix(hookName)}: blocking decision for ` +
485
+ `${decision.policyName} has NO audit row (${reason})\n`);
486
+ }
213
487
  },
214
488
  };
215
489
  }
@@ -271,7 +545,7 @@ export async function runInterceptCli(opts = {}) {
271
545
  const server = findGroundingMcp(manifest);
272
546
  ledger = server
273
547
  ? realLedgerClient(server, opts)
274
- : degradedLedgerClient("grounding-mcp not declared in manifest");
548
+ : degradedLedgerClient("grounding-mcp not declared in manifest", stderr, opts.hookName);
275
549
  }
276
550
  // For the SESSION_ID builtin we keep the empty-string fallback rather
277
551
  // than routing through resolveSessionId (which lands on "default").
@@ -303,45 +577,180 @@ export async function runInterceptCli(opts = {}) {
303
577
  // literal `preflight:`. An explicit env var still wins: it is the
304
578
  // operator's deliberate override of the derived value.
305
579
  //
306
- // Deliberately CWD-ONLY, not target-directory-aware: a command that
307
- // explicitly names a different repository (`git -C <B>`, `env -C
308
- // <B>`, a leading `cd <B> &&`) still resolves ${REPO}/${BRANCH} from
309
- // the hook's own cwd, so a preflight recorded for repo A still
310
- // authorises a command that explicitly reads repo B — unchanged from
311
- // the shipped 0.42.0 behaviour. A per-command target-directory
312
- // resolution was built and reviewed (run
313
- // 2026-07-27-gate-target-repo-resolution) but removed before shipping:
314
- // three consecutive review rounds each found a different way one
315
- // global `targetDir` per event leaked into the wrong evaluation (Risk
316
- // Gate risk declassification; a push-gate multi-invocation fail-open;
317
- // a non-git gated verb sharing a chain with a targeted git call, first
318
- // via `&&`, then still open via `|` / `||`). Each fix was partial
319
- // because a single per-event `targetDir` cannot express "this
320
- // invocation targets repo B, but the gated verb after it runs in the
321
- // caller's cwd" — the correct fix attributes a target to the SEGMENT
322
- // that satisfies a policy's own trigger, which needs the policy
323
- // builtins to become per-policy, not per-event. The extraction itself
324
- // (`command-normalize.ts`'s `targetDir`/`targetBase`) stays in the
325
- // tree, fully tested, as the foundation for that redesign — tracked as
326
- // follow-up task `98ad072f`.
580
+ // `cwdGitContext` below is the DEFAULT / FALLBACK builtins basis, not
581
+ // the only one used anymore (task `98ad072f`, T-003). A command that
582
+ // explicitly names a different repository (`git -C <B>`, `env -C <B>`,
583
+ // a leading `cd <B> &&`) still resolves `builtins.REPO`/`.BRANCH` (and
584
+ // `currentHeadSha`, below) from THIS cwd for the majority of policies —
585
+ // any policy whose `requires:` doesn't reference `${REPO}`/`${BRANCH}`/
586
+ // `at_head` stays exactly on this cwd-only path, as does any policy
587
+ // whose own trigger-satisfying segment names no foreign target. Only a
588
+ // MATCHED policy that (a) uses `${REPO}`/`${BRANCH}`/`at_head` and (b)
589
+ // whose own `bash_match`-satisfying segment names a different,
590
+ // resolvable git repository gets a per-policy override — resolved
591
+ // lazily, inside `intercept()`, from the `commandSegments` view threaded
592
+ // below, never from this `cwdGitContext`. A prior attempt (run
593
+ // `2026-07-27-gate-target-repo-resolution`) tried a single global
594
+ // `targetDir` per event instead and was removed before shipping: three
595
+ // consecutive review rounds each found a different way one event-wide
596
+ // target leaked into the wrong evaluation (Risk Gate risk
597
+ // declassification; a push-gate multi-invocation fail-open; a non-git
598
+ // gated verb sharing a chain with a targeted git call). This
599
+ // `cwdGitContext` variable, and the `builtins` object built from it
600
+ // below, remain exactly what they were before this task — the resolved
601
+ // fallback every policy without a foreign attribution still uses,
602
+ // including the Risk Gate's own `resolverGit` further down, which
603
+ // this task does not touch at all.
327
604
  const cwd = resolvePolicyCwd(event, opts.codexCommandCwd);
328
605
  const cwdGitContext = resolveGitContext(cwd);
329
- // Computed once here (not per-policy) so `intercept()` below can
330
- // thread the SAME `NormalizedCommand` into every `bash_match` trigger
331
- // check instead of each one recomputing it.
332
- const normalizedCommand = event.tool_name === "Bash"
333
- ? (() => {
334
- const cmd = readBashCommand(event.tool_input);
335
- return cmd === null ? undefined : normalizeCommand(cmd);
336
- })()
337
- : undefined;
606
+ // Computed once here (not per-policy) so `intercept()` below can thread
607
+ // the SAME `NormalizedCommand` into every `bash_match` trigger check
608
+ // instead of each one recomputing it.
609
+ //
610
+ // Fix round 1, finding F4: derive this from `extractShellCommand(event)`
611
+ // — the SAME function `policyMatchesEvent`'s `bash_match` branch itself
612
+ // calls (`src/runtime/tool-name-aliases.ts`) — instead of the narrower
613
+ // `event.tool_name === "Bash"` + `readBashCommand(event.tool_input)` pair
614
+ // this used before, which only ever looked at `tool_input.command`. A
615
+ // Codex shell event (`tool_name: "shell"` / `"exec_command"` /
616
+ // `"functions.exec_command"`, which `expandToolNameAliases` maps onto
617
+ // `match: "Bash"` policies) commonly carries its command under
618
+ // `raw_input`/`input` rather than `tool_input`, or under a `cmd` key —
619
+ // `extractShellCommand` reads all of those, `readBashCommand` read none
620
+ // of them. Before this fix such an event still reached the `bash_match`
621
+ // branch (via alias expansion) but with no precomputed value, so
622
+ // `policyMatchesEvent`'s per-policy fallback ran BOTH normalisation
623
+ // passes once PER MATCHING POLICY instead of once per event.
624
+ // `isCodexShellTool` scopes the precompute to exactly the tool-name set
625
+ // `SHELL_ALIASES` expands (Bash plus the three Codex shell aliases), so
626
+ // this does not start normalising unrelated tool calls that merely
627
+ // happen to carry a `command`/`cmd`-named argument for some other
628
+ // purpose.
629
+ //
630
+ // Fix round 2: that set is NOT, as this comment previously claimed, the
631
+ // only way a `match: "Bash"` policy's `bash_match` branch can be
632
+ // reached. `trigger.match` is a SUBSTRING test over the expanded alias
633
+ // list, so `BashOutput` and `KillBash` — real Claude Code tool names —
634
+ // satisfy `match: "Bash"` too and do reach that branch (measured: both
635
+ // return `policyMatchesEvent === true`). They simply fall through to
636
+ // the per-policy fallback here, exactly as they did before this change,
637
+ // so nothing is gated differently; the precompute just does not
638
+ // de-duplicate for them.
639
+ //
640
+ // This also makes the precomputed value and the matcher's own value
641
+ // PROVABLY consistent for the common case: both are now the same
642
+ // `extractShellCommand(event)` call, so the latent
643
+ // `raw_input`/`input`/`cmd` divergence `InterceptOptions.normalizedCommand`'s
644
+ // own JSDoc warns about (nothing verifies the threaded value came from
645
+ // the event's real command) can no longer actually diverge for a
646
+ // `tool_name` in `SHELL_ALIASES` — the one production call site
647
+ // (`runInterceptCli`) derives both from the identical event via the
648
+ // identical function. It remains an unenforced INVARIANT, not a checked
649
+ // one: nothing stops a future second caller from threading a mismatched
650
+ // value in, and `event.tool_input`/`raw_input`/`input` could still
651
+ // change between the moment this is read and any later read of the same
652
+ // event object, however unlikely in practice.
653
+ //
654
+ // TWO behavioural differences, both checked (fix round 2 corrects the
655
+ // prior wording, which named only the first):
656
+ //
657
+ // 1. `readBashCommand` required a NON-EMPTY string; `extractShellCommand`
658
+ // accepts an empty one. An empty command now becomes
659
+ // `bashCommand = ""` instead of `null`, so `normalizedCommand`
660
+ // becomes a defined `normalizeCommand("") = {normalized: "",
661
+ // truncated: false, ...}` instead of staying `undefined` —
662
+ // behaviourally identical either way (a pure function of the empty
663
+ // string, now computed once instead of falling back per policy), and
664
+ // `truncated` can never be `true` for an empty string, so the
665
+ // `MAX_NORMALIZE_LENGTH` stderr line just below cannot fire from it.
666
+ //
667
+ // 2. That stderr line's SCOPE widened. Under the old ternary
668
+ // `bashCommand` was unconditionally `null` for any non-`Bash`
669
+ // `tool_name`, so `normalizedCommand` stayed `undefined` and the
670
+ // guard could never fire for a Codex shell event, nor for a command
671
+ // carried under `raw_input`/`input` or a `cmd` key. Measured at
672
+ // HEAD: a `tool_name: "shell"` event with a 100,001-char
673
+ // `raw_input.cmd` now emits the line. The direction is
674
+ // safety-positive — a previously SILENT loss of normalised-form
675
+ // coverage on those events is now reported — but it is a real change
676
+ // in observable output, so it is named here rather than implied.
677
+ const bashCommand = isCodexShellTool(event.tool_name) ? extractShellCommand(event) : null;
678
+ const normalizedCommand = bashCommand === null ? undefined : normalizeCommand(bashCommand);
679
+ // Per-segment view for `intercept()`'s per-policy attribution (task
680
+ // `98ad072f`, T-003) — deferred behind a memoised thunk, mirroring
681
+ // `ampNormalizedCommandThunk` below (D-015 fix round, run
682
+ // 2026-08-02-per-repo-gate-scoping-redesign, correcting this comment's
683
+ // own prior claim). CORRECTED: this is NOT "the same class as
684
+ // `normalizeCommand`'s own eager call just above" — `segmentViewOf` runs
685
+ // its OWN full segmentation walk over `bashCommand` (`command-
686
+ // normalize.ts`'s `segmentAndCanonicalize`, a SEPARATE call from the one
687
+ // `normalizeCommand` just made on the SAME string), and computing it
688
+ // unconditionally, for every Bash event, regardless of whether any
689
+ // policy in the manifest even uses `${REPO}`/`${BRANCH}`/`at_head`, was
690
+ // measured as a real, avoidable second pass — +206% at
691
+ // `MAX_NORMALIZE_LENGTH` (2.73 → 8.36 ms; see `segmentViewOf`'s own doc
692
+ // comment in `command-normalize.ts`). Threading it as a self-memoising
693
+ // thunk instead — the exact shape `ampNormalizedCommandThunk` below
694
+ // already uses for the same reason — lets `intercept()`'s
695
+ // `usesPerRepoBuiltins` gate decide whether the walk happens at all: a
696
+ // manifest with none of those policies never calls `segmentViewOf`, and
697
+ // a manifest that DOES still only pays the cost once per event, on
698
+ // first use, not once per Bash event regardless of need.
699
+ let commandSegmentsCache;
700
+ const commandSegmentsThunk = bashCommand === null
701
+ ? undefined
702
+ : () => {
703
+ if (commandSegmentsCache === undefined) {
704
+ commandSegmentsCache = segmentViewOf(bashCommand);
705
+ }
706
+ return commandSegmentsCache;
707
+ };
708
+ // Memoised thunk for the ampersand-aware SECOND normalisation pass
709
+ // (task aabbad63). Unlike `normalizedCommand` above — eagerly computed
710
+ // once per Bash event, because `policyMatchesEvent`'s first two arms
711
+ // may need it on every raw-miss — the amp-aware pass is consulted only
712
+ // as a THIRD, additional arm, and only when a policy's regex has
713
+ // already missed BOTH the raw command and `normalizedCommand`; most
714
+ // events never reach it at all. Computing it eagerly here would pay
715
+ // its segmentation cost on the common path for every single Bash call
716
+ // inside the ~1000ms hook budget, whether or not any policy ends up
717
+ // needing it. Wrapping it in a lazily-evaluated, self-memoising thunk
718
+ // instead lets `policyMatchesEvent` call it from inside the manifest-
719
+ // wide `matching` loop below (once per policy that still hasn't
720
+ // matched) while the actual segmentation work happens AT MOST ONCE per
721
+ // event — the same "computed once per event, not once per policy"
722
+ // discipline `normalizedCommand` already has, just deferred until
723
+ // first use instead of forced up front.
724
+ let ampNormalizedCommandCache;
725
+ const ampNormalizedCommandThunk = bashCommand === null
726
+ ? undefined
727
+ : () => (ampNormalizedCommandCache ??= normalizeCommandAmpAware(bashCommand));
728
+ // Memoised thunk for the quote-aware THIRD normalisation pass (task
729
+ // cf3dff51) — the SAME shape as `ampNormalizedCommandThunk` immediately
730
+ // above, for the same reason: `policyMatchesEvent`'s fourth arm calls
731
+ // this only when a policy's regex has already missed the raw command,
732
+ // `normalizedCommand`, AND the amp-aware pass, so most events never
733
+ // reach it at all; computing it eagerly here would pay its
734
+ // segmentation cost on the common path for every Bash call regardless
735
+ // of whether any policy ends up needing it.
736
+ let quoteNormalizedCommandCache;
737
+ const quoteNormalizedCommandThunk = bashCommand === null
738
+ ? undefined
739
+ : () => (quoteNormalizedCommandCache ??= normalizeCommandQuoteAware(bashCommand));
338
740
  // Above `MAX_NORMALIZE_LENGTH`, `normalizeCommand` skips normalisation
339
741
  // entirely and `truncated` comes back `true`. Raw matching still
340
- // applies regardless (`policyMatchesEvent`'s raw-OR-normalised
341
- // construction), so an oversized command only loses the ADDITIONAL
342
- // normalised-form coverage — but that skip must not be silent, so one
343
- // stderr line reports it here, keeping the module itself pure and
344
- // I/O-free.
742
+ // applies regardless (`policyMatchesEvent`'s raw-OR-normalised-OR-amp-
743
+ // normalised-OR-quote-normalised construction), so an oversized command
744
+ // only loses the ADDITIONAL normalised-form coverage — but that skip
745
+ // must not be silent, so one stderr line reports it here, keeping the
746
+ // module itself pure and I/O-free. This one line also covers the
747
+ // amp-aware SECOND pass (`normalizeCommandAmpAware`, task `aabbad63`,
748
+ // fix round 1 finding F6) and the quote-aware THIRD pass
749
+ // (`normalizeCommandQuoteAware`, task `cf3dff51`): both share the
750
+ // identical `MAX_NORMALIZE_LENGTH` bound over the identical
751
+ // `bashCommand`, so neither one's own `truncated` can ever disagree
752
+ // with `normalizedCommand`'s for this call — no separate stderr line
753
+ // names either pass's own skip, nor does one need to.
345
754
  if (normalizedCommand?.truncated === true) {
346
755
  stderr.write(`harness policy intercept${hookSuffix(opts.hookName)}: Bash command exceeds ${MAX_NORMALIZE_LENGTH} chars; normalised-form matching skipped for this call (raw match only)\n`);
347
756
  }
@@ -364,21 +773,23 @@ export async function runInterceptCli(opts = {}) {
364
773
  namespace: opts.kubeNamespace ?? "",
365
774
  }
366
775
  : resolveKubeContext();
367
- // Two POSIX Bash idioms (`VAR=value command`, `cd <path> && command`)
368
- // were invisible to the resolver before: the env resolver read
369
- // `process.env` and the branch resolver read `.git/HEAD` under the
370
- // hook's starting cwd, so a prod signal smuggled through either
371
- // idiom silently passed the gate. Parse the leading prefix once from
372
- // the Bash command and merge the result into the resolver inputs
373
- // only. ${CWD} always names the hook's own cwd; ${REPO}/${BRANCH}
374
- // (`cwdGitContext` above, feeding `builtins`) are cwd-only too — see
375
- // the comment above `cwdGitContext`'s declaration. The Risk Gate
376
- // resolver's git base, `resolverGit` below, is its own independent
377
- // narrow parse: it recognises only a leading `cd`, never `git -C` /
378
- // `--work-tree` / `--git-dir` / `env -C`. Bottom line: `resolverGit`
379
- // must never read anything other than `cwdGitContext`, or the
380
- // leading-`cd` target within THIS same narrow parse — this feeds
381
- // `when:`-clause risk classification, not ledger-tag namespacing.
776
+ // Three POSIX Bash idioms (`VAR=value command`, `cd <path> &&
777
+ // command`, and — since task 341e024b — `git switch|checkout
778
+ // <branch> && command`) were invisible to the resolver before: the
779
+ // env resolver read `process.env` and the branch resolver read
780
+ // `.git/HEAD` under the hook's starting cwd, so a prod signal
781
+ // smuggled through any of the three idioms silently passed the
782
+ // gate. Parse the leading prefix once from the Bash command and
783
+ // merge the result into the resolver inputs only. ${CWD} always
784
+ // names the hook's own cwd; ${REPO}/${BRANCH} (`cwdGitContext`
785
+ // above, feeding `builtins`) are cwd-only too — see the comment
786
+ // above `cwdGitContext`'s declaration. The Risk Gate resolver's git
787
+ // base, `resolverGit` below, is its own independent narrow parse: it
788
+ // recognises only a leading `cd`, never `git -C` / `--work-tree` /
789
+ // `--git-dir` / `env -C`. Bottom line: `resolverGit` must never read
790
+ // anything other than `cwdGitContext`, or the leading-`cd` target
791
+ // within THIS same narrow parse — this feeds `when:`-clause risk
792
+ // classification, not ledger-tag namespacing.
382
793
  //
383
794
  // G5 (review round 2, 2026-07-27): that leading-`cd` parse is itself
384
795
  // still a live declassification lever, PRE-EXISTING and NOT changed
@@ -395,7 +806,13 @@ export async function runInterceptCli(opts = {}) {
395
806
  // branch, as in the example above) — `resolverGit` trusts whichever
396
807
  // of the two the command happens to name, with no independent check
397
808
  // that the `cd` target is the repo the destructive command actually
398
- // runs against.
809
+ // runs against. The `git switch`/`checkout` merge added below
810
+ // (`gitForRisk`, via `applyBranchSwitchUpgrade`) deliberately does
811
+ // NOT share this bidirectional-risk shape: it is a SEPARATE,
812
+ // upgrade-only step layered on top of `resolverGit`, not a change to
813
+ // `resolverGit` itself — a switch away from a production branch can
814
+ // never downgrade what `resolverGit` (cwd- or cd-based) already
815
+ // resolved.
399
816
  const bashPrefix = event.tool_name === "Bash"
400
817
  ? (() => {
401
818
  const cmd = readBashCommand(event.tool_input);
@@ -421,11 +838,18 @@ export async function runInterceptCli(opts = {}) {
421
838
  // win over process.env (matches POSIX `VAR=value cmd` semantics).
422
839
  return { ...base, ...bashPrefix.inlineEnv };
423
840
  })();
841
+ const riskUser = safeOs(() => os.userInfo().username);
842
+ const riskHost = safeOs(() => os.hostname());
843
+ // A leading `git switch`/`checkout <branch>` (task 341e024b) is
844
+ // merged on top of `resolverGit` above — upgrade-only, see
845
+ // `applyBranchSwitchUpgrade`'s own doc comment for why this one is
846
+ // NOT a straight replacement the way the `cd` merge is.
847
+ const gitForRisk = applyBranchSwitchUpgrade(event, manifest, cwd, resolverGit, bashPrefix?.branchTarget ?? null, { env: resolverEnv, kubeContext: kube.context, kubeNamespace: kube.namespace }, riskUser, riskHost, opts.now);
424
848
  riskContext = {
425
- git: resolverGit,
849
+ git: gitForRisk,
426
850
  cwd,
427
- user: safeOs(() => os.userInfo().username),
428
- host: safeOs(() => os.hostname()),
851
+ user: riskUser,
852
+ host: riskHost,
429
853
  env: resolverEnv,
430
854
  kubeContext: kube.context,
431
855
  kubeNamespace: kube.namespace,
@@ -444,6 +868,11 @@ export async function runInterceptCli(opts = {}) {
444
868
  ...(cwdGitContext.sha.length > 0 && { currentHeadSha: cwdGitContext.sha }),
445
869
  ...(riskContext && { riskContext }),
446
870
  ...(normalizedCommand && { normalizedCommand }),
871
+ ...(ampNormalizedCommandThunk && { ampNormalizedCommandThunk }),
872
+ ...(quoteNormalizedCommandThunk && { quoteNormalizedCommandThunk }),
873
+ ...(commandSegmentsThunk && { commandSegmentsThunk }),
874
+ ...(process.env.HARNESS_REPO !== undefined && { repoOverridden: true }),
875
+ ...(process.env.HARNESS_BRANCH !== undefined && { branchOverridden: true }),
447
876
  });
448
877
  }
449
878
  finally {
@@ -458,14 +887,18 @@ export async function runInterceptCli(opts = {}) {
458
887
  // the producer side knows the live session id (it just received it on
459
888
  // the hook event), but `harness approve risk` runs from the operator's
460
889
  // `!`-shell where `$CLAUDE_SESSION_ID` is unset, so it has to read the
461
- // marker. `deny` decisions are deliberately not staged: `harness approve
462
- // risk` cannot unblock a `deny`, and writing a marker the verb cannot
463
- // act on would just lie about the recoverability of the block. The
464
- // "first blocking decision" check (rather than `.some()`) matters when
465
- // two `when:`-bearing policies fire on the same event: the runtime's
466
- // `intercept()` picks the first blocking decision (`runtime/intercept.ts:511`),
467
- // so a `deny`-first / `require_approval`-second order produces an
468
- // unrecoverable block; we mustn't stage in that case either.
890
+ // marker. `deny` and `deny-degraded` decisions are deliberately not
891
+ // staged: `harness approve risk` cannot unblock either (a deny is a
892
+ // real verdict, a deny-degraded means the evidence source is
893
+ // unreadable, so no approval tag could even be read), and writing a
894
+ // marker the verb cannot act on would just lie about the
895
+ // recoverability of the block. The "first blocking decision" check
896
+ // (rather than `.some()`) matters when two `when:`-bearing policies
897
+ // fire on the same event: the runtime's `intercept()` picks the first
898
+ // blocking decision (its `isBlockingDecision`, imported here so the
899
+ // two surfaces cannot drift — review 2026-08-08), so a blocking-first
900
+ // / `require_approval`-second order produces an unrecoverable block;
901
+ // we mustn't stage in that case either.
469
902
  //
470
903
  // Best-effort: a staging-write failure must never escalate a gate block
471
904
  // into a thrown hook error. `resolveGeneratedDir` is skipped (along
@@ -473,8 +906,7 @@ export async function runInterceptCli(opts = {}) {
473
906
  // exercise the policy logic with injected manifests and no on-disk
474
907
  // path, and the test path is also the only place an empty
475
908
  // `eventSessionId` is interesting.
476
- const firstBlocking = result.decisions.find((d) => (d.outcome === "deny" && d.enforcement === "block") ||
477
- d.outcome === "require_approval");
909
+ const firstBlocking = result.decisions.find(isBlockingDecision);
478
910
  if (result.blockJson &&
479
911
  eventSessionId !== undefined &&
480
912
  eventSessionId.length > 0 &&
@@ -509,6 +941,33 @@ export async function runInterceptCli(opts = {}) {
509
941
  if (result.decisions.length === 0 && manifest.policies.length > 0) {
510
942
  stderr.write(formatNoMatchHint(event, manifest, opts.hookName));
511
943
  }
944
+ // Degraded-deny operator hint. Default-verbosity, ONE line per event:
945
+ // the envelope deliberately tells the agent only to involve the
946
+ // operator, and the full diagnostic (which names the fail_open
947
+ // opt-out) is verbose-only — without this line an operator whose
948
+ // fleet wedges on a degraded ledger has no default-verbosity pointer
949
+ // at all (review 2026-08-08, round 2). Two deliberate properties:
950
+ // - It carries the decision's OWN reason (sanitised: it can embed
951
+ // grounding-mcp subprocess output) instead of asserting a ledger
952
+ // fault — deny-degraded has five causes and only one of them is
953
+ // the ledger; a hardcoded "ledger unreadable" sent operators of
954
+ // the other four down a false doctor-path (round 3, medium).
955
+ // - It does NOT name the opt-out knob: hook stderr can be surfaced
956
+ // to the model in some harness configurations, so the default
957
+ // channel points at the operator docs and the verbose diagnostic
958
+ // instead of carrying the disable recipe (deviation from round
959
+ // 2's suggested fix, recorded in the run's 03-decisions.md).
960
+ // Suppressed under --verbose, where formatDecisionDiagnostic already
961
+ // prints the same reason with more context.
962
+ const firstDegradedDeny = result.decisions.find((d) => d.outcome === "deny-degraded");
963
+ if (firstDegradedDeny && !verbose) {
964
+ stderr.write(`harness policy intercept${hookSuffix(opts.hookName)}: ${firstDegradedDeny.policyName}: ` +
965
+ `deny-degraded (evidence could not be evaluated: ` +
966
+ `${sanitizeEnvelopeReason(firstDegradedDeny.reason)}); failing closed. ` +
967
+ `Operator recovery: check the reason above, then harness doctor if it names ` +
968
+ `the ledger; details and the availability opt-out: docs/risk-gate.md ` +
969
+ `("Degraded mode") or rerun with HARNESS_POLICY_VERBOSE=1\n`);
970
+ }
512
971
  if (verbose) {
513
972
  for (const decision of result.decisions) {
514
973
  if (decision.outcome === "allow")