@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
@@ -5,13 +5,16 @@
5
5
  // the decisions and the Claude Code deny-JSON (or null when all allow).
6
6
  // Side effects (stdin, stdout, ledger I/O) live in the thin CLI entrypoint
7
7
  // that wraps this.
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
8
10
  import { evaluateExtract, evaluateRequires, parseDurationSeconds, substituteTemplate, } from "../policies/index.js";
9
11
  import { renderProducers } from "../policies/producers.js";
10
12
  import { buildActionEnvelope } from "./action-envelope.js";
11
13
  import { renderAgentFacing } from "./agent-facing.js";
12
- import { normalizeCommand } from "./command-normalize.js";
14
+ import { normalizeCommand, normalizeCommandAmpAware, normalizeCommandQuoteAware, segmentViewOf, } from "./command-normalize.js";
13
15
  import { resolveEnvironment, } from "./environment-resolver.js";
14
- import { POLICY_DECISION_TYPE } from "./ledger-record.js";
16
+ import { resolveGitContext } from "./git-context.js";
17
+ import { POLICY_DECISION_TYPE } from "../io/ledger-record.js";
15
18
  import { classifyRisk } from "./risk-classifier.js";
16
19
  import { resolveSessionId } from "./session-id.js";
17
20
  import { expandToolNameAliases, extractShellCommand, } from "./tool-name-aliases.js";
@@ -57,8 +60,22 @@ function enrichEnvelope(manifest, event, riskContext, now) {
57
60
  * tests), which fall back to computing it lazily right here — correct
58
61
  * either way, since `normalizeCommand` is a pure function of the
59
62
  * command string alone.
63
+ *
64
+ * `ampNormalizedCommandThunk` (task aabbad63) is the SAME
65
+ * resolved-by-the-wrapper pattern for the ampersand-aware SECOND
66
+ * normalisation pass, but threaded as a memoised THUNK rather than a
67
+ * precomputed value — see `InterceptOptions.ampNormalizedCommandThunk`
68
+ * for why laziness matters here specifically. Omitted callers fall back
69
+ * to calling `normalizeCommandAmpAware` directly, same shape as the
70
+ * `precomputedNormalizedCommand` fallback above.
71
+ *
72
+ * `quoteNormalizedCommandThunk` (task cf3dff51) is the SAME thunk pattern
73
+ * again, for the quote-aware THIRD normalisation pass — see
74
+ * `InterceptOptions.quoteNormalizedCommandThunk`. Omitted callers fall
75
+ * back to calling `normalizeCommandQuoteAware` directly, same shape as
76
+ * the two fallbacks above.
60
77
  */
61
- export function policyMatchesEvent(policy, event, precomputedNormalizedCommand) {
78
+ export function policyMatchesEvent(policy, event, precomputedNormalizedCommand, ampNormalizedCommandThunk, quoteNormalizedCommandThunk) {
62
79
  if (policy.trigger.event !== event.hook_event_name)
63
80
  return false;
64
81
  if (policy.trigger.match !== undefined) {
@@ -80,23 +97,43 @@ export function policyMatchesEvent(policy, event, precomputedNormalizedCommand)
80
97
  catch {
81
98
  return false;
82
99
  }
83
- // Raw-OR-normalised (D-003, run 2026-07-27-gate-target-repo-
84
- // resolution): test the RAW command first — cheap, and byte-
85
- // identical to the pre-fix behaviour — then, only if that fails,
86
- // fall back to the NORMALISED command (wrapper prefixes peeled, git
87
- // global options dropped, whitespace collapsed). Strictly additive:
88
- // a command that matched today keeps matching via the raw test
89
- // alone; normalisation can only ADD a match a spelling would
90
- // otherwise have missed (env/nice/command wrappers, extra git
91
- // global options, doubled whitespace), never remove the raw one.
92
- // Replacing the matcher input instead of OR-ing it risks silently
93
- // REMOVING an existing match if normalisation ever mangles some
94
- // shape — the fail-open direction for a gate — so this stays
95
- // additive rather than a substitution.
100
+ // Raw-OR-normalised-OR-amp-normalised-OR-quote-normalised (D-003, run
101
+ // 2026-07-27-gate-target-repo-resolution; third arm added task
102
+ // aabbad63; fourth arm added task cf3dff51): test the RAW command
103
+ // first — cheap, and byte-identical to the pre-fix behaviour — then,
104
+ // only if that fails, the primary NORMALISED command (wrapper
105
+ // prefixes peeled, git global options dropped, whitespace collapsed,
106
+ // BOUNDARY_RE segmentation) — then, only if THAT also fails, the
107
+ // ampersand-aware second pass (AMP_BOUNDARY_RE segmentation, closing
108
+ // the bare-`&` family BOUNDARY_RE cannot see: `A=x&env -C /tmp git
109
+ // status`, `echo hi & nice git status`) — then, only if THAT also
110
+ // fails, the quote-aware third pass (BOUNDARY_RE's own alphabet, but
111
+ // quote-tracking, closing a shell-boundary character sitting INSIDE a
112
+ // quoted assignment value: `VAR='a; b' git push origin master`).
113
+ // Strictly additive at every step: a command that matched today keeps
114
+ // matching via the raw test alone; the primary normalised form can
115
+ // only ADD a match (env/nice/command wrappers, extra git global
116
+ // options, doubled whitespace); the amp-aware form can only add a
117
+ // FURTHER match on top of those two; the quote-aware form can only
118
+ // add a FOURTH match on top of all three, never remove one any of the
119
+ // others already found. Replacing the matcher input instead of OR-ing
120
+ // it in risks silently REMOVING an existing match if some pass ever
121
+ // mangles a shape — the fail-open direction for a gate — so this
122
+ // stays additive rather than a substitution at every arm.
96
123
  if (!re.test(command)) {
97
124
  const { normalized } = precomputedNormalizedCommand ?? normalizeCommand(command);
98
- if (!re.test(normalized))
99
- return false;
125
+ if (!re.test(normalized)) {
126
+ const amp = ampNormalizedCommandThunk
127
+ ? ampNormalizedCommandThunk()
128
+ : normalizeCommandAmpAware(command);
129
+ if (!re.test(amp.normalized)) {
130
+ const quoted = quoteNormalizedCommandThunk
131
+ ? quoteNormalizedCommandThunk()
132
+ : normalizeCommandQuoteAware(command);
133
+ if (!re.test(quoted.normalized))
134
+ return false;
135
+ }
136
+ }
100
137
  }
101
138
  }
102
139
  return true;
@@ -131,13 +168,79 @@ function outcomeForFailedRequires(enforcement) {
131
168
  * `requires:` (an operator runs `harness approve risk`); once the
132
169
  * tag is on record the requires evaluation passes and the outcome
133
170
  * is `allow` instead.
171
+ * - `deny-degraded` aborts (task f1aea826): it is only ever produced
172
+ * for `block` / `require_approval` policies under the default
173
+ * `preserve_enforcement` posture (see `degradedOutcome`), so its
174
+ * presence alone means "an incident-preventing gate could not read
175
+ * its evidence" — fail closed.
134
176
  * - `allow` / `warn` / `warn-degraded` never abort.
177
+ *
178
+ * Exported for the CLI wrapper's pending-approval staging
179
+ * (`src/cli/policy/intercept.ts`), which must agree with the runtime on
180
+ * WHICH decision is the first blocking one — a hand-rolled copy there
181
+ * drifted when `deny-degraded` was added (review 2026-08-08, low
182
+ * finding). Keep this the single definition.
135
183
  */
136
- function isBlockingDecision(d) {
184
+ export function isBlockingDecision(d) {
137
185
  if (d.outcome === "deny")
138
186
  return d.enforcement === "block";
187
+ if (d.outcome === "deny-degraded")
188
+ return true;
139
189
  return d.outcome === "require_approval";
140
190
  }
191
+ /**
192
+ * Outcome for a policy whose `requires` could NOT be evaluated at all —
193
+ * degraded/thrown ledger query, unresolved template variables, invalid
194
+ * `within`, thrown `evaluateRequires`, or the defensive
195
+ * schema-invariant branch. The fail posture is derived from the
196
+ * policy's own `enforcement:` (task f1aea826):
197
+ *
198
+ * warn → `warn-degraded` (availability first,
199
+ * unchanged: advisory friction never
200
+ * bricks the session)
201
+ * block / require_approval → `deny-degraded` (fail closed: a gate
202
+ * against an irreversible incident must
203
+ * not open because its evidence source
204
+ * is unreadable)
205
+ *
206
+ * The manifest-level opt-out `risk.degraded_fail_posture: fail_open`
207
+ * restores the pre-0.45 availability-first mapping (`warn-degraded`
208
+ * for every tier). The hand-built-manifest case (`options.manifest.risk`
209
+ * absent, only possible past `harness validate`) defaults to the
210
+ * fail-closed posture, matching the schema default.
211
+ */
212
+ function degradedOutcome(policy, manifest) {
213
+ if (policy.enforcement === "warn")
214
+ return "warn-degraded";
215
+ const posture = manifest.risk?.degraded_fail_posture ?? "preserve_enforcement";
216
+ return posture === "fail_open" ? "warn-degraded" : "deny-degraded";
217
+ }
218
+ /**
219
+ * Control characters (C0 range plus DEL), built via fromCharCode so the
220
+ * source file itself stays free of raw control bytes. Matches what the
221
+ * envelope sanitiser collapses to a single space.
222
+ */
223
+ const ENVELOPE_CONTROL_CHARS = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]+`, "g");
224
+ /**
225
+ * Bound and clean a transport-level reason before it is interpolated
226
+ * into the agent-facing deny-degraded envelope. The string can embed
227
+ * output captured from the grounding-mcp SUBPROCESS (`exitDiagnostic`
228
+ * in `src/policies/ledger-client.ts` appends the child's last stderr
229
+ * line), i.e. untrusted content that previously only reached stderr
230
+ * and the audit ledger, never model-visible text. The full, untouched
231
+ * string still goes to the audit row and the verbose diagnostic; only
232
+ * the envelope interpolation and the default-verbosity operator hint
233
+ * are bounded.
234
+ *
235
+ * Exported for the CLI wrapper's deny-degraded operator hint (review
236
+ * 2026-08-08, round 3; default-verbosity only, suppressed under
237
+ * verbose), which interpolates the same untrusted reason into its
238
+ * one-line stderr surface.
239
+ */
240
+ export function sanitizeEnvelopeReason(reason) {
241
+ const stripped = reason.replace(ENVELOPE_CONTROL_CHARS, " ");
242
+ return stripped.length > 200 ? `${stripped.slice(0, 200)}...` : stripped;
243
+ }
141
244
  /**
142
245
  * Placeholder `ledgerTag` recorded on an `operator_only` decision (and on
143
246
  * the defensive schema-invariant-violated branch below). Both outcomes
@@ -178,21 +281,23 @@ async function evaluateOnePolicy(policy, options) {
178
281
  // Defensive branch only, for a manifest that bypassed
179
282
  // `harness validate` (hand-built Policy object, stale cached parse).
180
283
  //
181
- // Deliberately `warn-degraded`, not `deny`: this is the SAME "could
182
- // not decide" fail-open family as the other `warn-degraded` returns
183
- // in this function (unresolved template variables, a degraded
184
- // ledger query, an invalid `within`, a throwing `evaluateRequires`)
185
- // — the contract for all of them is "the evaluator could not form a
186
- // real verdict, so it never blocks and never silently allows either;
187
- // it is loud instead" (recorded to the audit ledger and returned as
188
- // a non-`allow`, non-`deny` outcome). Failing this branch `deny`
189
- // would treat an internal schema-invariant violation as if it were a
190
- // deliberate `operator_only: true` policy authored by the operator,
191
- // which it is not — the two must stay observably distinct.
284
+ // Deliberately NOT a plain `deny`: this is the SAME "could not
285
+ // decide" family as the other degraded returns in this function
286
+ // (unresolved template variables, a degraded ledger query, an
287
+ // invalid `within`, a throwing `evaluateRequires`) — the contract
288
+ // for all of them is "the evaluator could not form a real verdict",
289
+ // routed through `degradedOutcome` so the policy's own enforcement
290
+ // decides the fail posture (task f1aea826): `warn` stays the
291
+ // non-blocking `warn-degraded`, `block`/`require_approval` fail
292
+ // closed as `deny-degraded`. Failing this branch as plain `deny`
293
+ // would treat an internal schema-invariant violation as if it were
294
+ // a deliberate `operator_only: true` policy authored by the
295
+ // operator, which it is not — `deny-degraded` keeps the two
296
+ // observably distinct in every audit row and envelope.
192
297
  return {
193
298
  policyName: policy.name,
194
299
  enforcement: policy.enforcement,
195
- outcome: "warn-degraded",
300
+ outcome: degradedOutcome(policy, options.manifest),
196
301
  reason: "policy declares neither requires: nor operator_only: true (schema invariant violated)",
197
302
  extractValues: extract.values,
198
303
  ledgerTag: NO_LEDGER_TAG,
@@ -209,7 +314,7 @@ async function evaluateOnePolicy(policy, options) {
209
314
  return {
210
315
  policyName: policy.name,
211
316
  enforcement: policy.enforcement,
212
- outcome: "warn-degraded",
317
+ outcome: degradedOutcome(policy, options.manifest),
213
318
  reason: `template variables unresolved: ${unresolved.join(", ")}`,
214
319
  extractValues: extract.values,
215
320
  ledgerTag,
@@ -231,7 +336,7 @@ async function evaluateOnePolicy(policy, options) {
231
336
  return {
232
337
  policyName: policy.name,
233
338
  enforcement: policy.enforcement,
234
- outcome: "warn-degraded",
339
+ outcome: degradedOutcome(policy, options.manifest),
235
340
  reason: queryResult.reason,
236
341
  extractValues: extract.values,
237
342
  ledgerTag,
@@ -248,7 +353,7 @@ async function evaluateOnePolicy(policy, options) {
248
353
  return {
249
354
  policyName: policy.name,
250
355
  enforcement: policy.enforcement,
251
- outcome: "warn-degraded",
356
+ outcome: degradedOutcome(policy, options.manifest),
252
357
  reason: `invalid within: ${requires.within}`,
253
358
  extractValues: extract.values,
254
359
  ledgerTag,
@@ -272,7 +377,7 @@ async function evaluateOnePolicy(policy, options) {
272
377
  return {
273
378
  policyName: policy.name,
274
379
  enforcement: policy.enforcement,
275
- outcome: "warn-degraded",
380
+ outcome: degradedOutcome(policy, options.manifest),
276
381
  reason: `requires eval threw: ${err.message}`,
277
382
  extractValues: extract.values,
278
383
  ledgerTag,
@@ -323,6 +428,325 @@ function filterEntriesByTag(entries, tag) {
323
428
  (e.content.includes(tag) ||
324
429
  (e.source !== undefined && e.source.includes(tag))));
325
430
  }
431
+ /**
432
+ * Which of a policy's own trigger-satisfying segments name a target — the
433
+ * attribution sibling of `policyMatchesEvent` (task `98ad072f`, T-003).
434
+ * Re-tests the policy's OWN `bash_match` regex against each segment's
435
+ * `text` in isolation: every shipped `bash_match` alternation includes a
436
+ * `^` branch, so testing it against one segment's canonicalised text
437
+ * alone (no leading boundary character — see `CommandSegment.text`'s own
438
+ * doc comment) is well-defined. Returns every segment that matches on its
439
+ * own — usually one, occasionally several (D-004: a decoy read and a cwd
440
+ * read chained in one command can each independently satisfy the SAME
441
+ * trigger).
442
+ *
443
+ * Never changes WHETHER a policy matches — `policyMatchesEvent` already
444
+ * decided that from the WHOLE command (raw-or-normalised-or-amp-
445
+ * normalised), unchanged by this function. This only narrows down WHICH
446
+ * segment(s), if any, are individually responsible for the match, so
447
+ * `intercept()` below knows which segment's target — if any — to resolve
448
+ * the `${REPO}`/`${BRANCH}`/`currentHeadSha` builtins from instead of the
449
+ * event's own cwd. `[]` when the policy has no `bash_match` trigger (an
450
+ * MCP-tool-name-triggered policy — no segment concept applies), when its
451
+ * regex is malformed (mirrors `policyMatchesEvent`'s own defensive
452
+ * `try/catch`), or when the whole-command match came ONLY from the
453
+ * ampersand-aware third arm: `segments` here is always built from the
454
+ * PRIMARY (`BOUNDARY_RE`) segmentation, which — by construction — cannot
455
+ * itself contain the bare-`&` split the amp arm relies on, so an amp-
456
+ * only match finds no individually-matching segment and this returns
457
+ * `[]` (D-003: cwd builtins, identical to shipped).
458
+ */
459
+ export function attributeTriggerSegments(policy, segments) {
460
+ if (policy.trigger.bash_match === undefined)
461
+ return [];
462
+ let re;
463
+ try {
464
+ re = new RegExp(policy.trigger.bash_match);
465
+ }
466
+ catch {
467
+ return [];
468
+ }
469
+ return segments.filter((seg) => re.test(seg.text));
470
+ }
471
+ /**
472
+ * Does a policy's `requires:` reference the per-repo `${REPO}`/`${BRANCH}`
473
+ * builtins, or ask for `at_head`? Only these policies pay the per-policy
474
+ * attribution cost below — every other matching policy keeps the plain,
475
+ * per-event cwd builtins `options.builtins` already carries, byte-
476
+ * identical to before this task. D-005: `at_head` is included even when
477
+ * `ledger_tag` itself doesn't reference `${REPO}`/`${BRANCH}`, because
478
+ * `at_head` compares against `currentHeadSha`, which this task resolves
479
+ * from the SAME per-policy context — splitting the two would check
480
+ * `at_head` against the cwd's HEAD while `${REPO}` (if present elsewhere)
481
+ * named a different repo, the worse inconsistency D-005 rejects.
482
+ */
483
+ function usesPerRepoBuiltins(policy) {
484
+ const requires = policy.requires;
485
+ if (requires === undefined)
486
+ return false;
487
+ return (requires.at_head === true ||
488
+ requires.ledger_tag.includes("${REPO}") ||
489
+ requires.ledger_tag.includes("${BRANCH}"));
490
+ }
491
+ /**
492
+ * Bound on the number of DISTINCT attributed contexts one policy is
493
+ * evaluated against for one event (D-013, fix round, run
494
+ * 2026-08-02-per-repo-gate-scoping-redesign). Each distinct context costs
495
+ * one ledger query and one audit write in `intercept()`'s evaluation loop
496
+ * below; on a manifest with several per-repo-builtins policies (the
497
+ * shipped `FULL_TEMPLATE` has four), an event naming K distinct targets
498
+ * amplifies to 4K queries/writes, unbounded by `MAX_NORMALIZE_LENGTH` —
499
+ * measured 200/200 at K=200 by reviewer 2, on a hook budget whose timeout
500
+ * is ALLOW (same class as the 07-27 quadratic hot-path fail-open: a slow
501
+ * enough event silently passes). `resolveAttributedContexts` returns a
502
+ * `"bounded"` result instead of a `contexts` array once a policy's DISTINCT
503
+ * targets would exceed this constant; `intercept()` denies that policy
504
+ * directly (naming the ambiguity) without querying the ledger for any of
505
+ * them, rather than silently evaluating all of them.
506
+ */
507
+ export const MAX_ATTRIBUTED_CONTEXTS = 4;
508
+ /**
509
+ * Realpath a path for identity comparison, never throwing (D-012, fix
510
+ * round, run 2026-08-02-per-repo-gate-scoping-redesign). Falls back to the
511
+ * LEXICAL path unchanged when the target does not exist or is otherwise
512
+ * unreadable (`fs.realpathSync.native` throws `ENOENT` for a path this
513
+ * module was never guaranteed to have on disk, and this function must
514
+ * never throw — same fail-safe posture as `resolveGitContext` itself,
515
+ * which returns empty strings rather than throwing on a bad path).
516
+ * `.native` (not the plain JS `fs.realpathSync`) resolves via the OS
517
+ * syscall directly — cheaper, and avoids Node's own pure-JS symlink-loop
518
+ * bookkeeping for a value this function only uses for an identity
519
+ * comparison, never for a filesystem walk of its own.
520
+ */
521
+ function realpathOrSelf(p) {
522
+ try {
523
+ return fs.realpathSync.native(p);
524
+ }
525
+ catch {
526
+ return p;
527
+ }
528
+ }
529
+ /**
530
+ * Resolve the distinct `${REPO}`/`${BRANCH}`/`currentHeadSha` contexts a
531
+ * `usesPerRepoBuiltins` policy must be evaluated against (task `98ad072f`,
532
+ * T-003, `01-plan.md` Proposed Approach items 2-4). The engine trusts a
533
+ * trigger-satisfying segment's `effectiveTarget` UNIFORMLY as the
534
+ * directory that segment's own invocation genuinely runs in (bash
535
+ * semantics) — orchestrator decision D-010, 2026-08-02: an earlier
536
+ * revision of this function additionally distrusted an inherited target
537
+ * whenever a DIFFERENT invocation intervened between the `cd` and the
538
+ * satisfying segment — REJECTED: that coupling has no basis in bash's own
539
+ * semantics, is dodgeable by inserting any harmless read between the `cd`
540
+ * and the gated verb, and carried its own unverified `cd`-recognition
541
+ * gap. The `cd <B> && git log && git push` shape this was meant to guard
542
+ * is not a regression at all under this design — `git push` genuinely
543
+ * runs inside B — see `tests/runtime/intercept-cli.test.ts`'s "leading-cd
544
+ * is now a deliverable" block.
545
+ *
546
+ * D-021 (UNIVERSAL-ADDITIVE, operator decision, fix round, run
547
+ * 2026-08-02-per-repo-gate-scoping-redesign): attribution is uniformly
548
+ * ADDITIVE, never REPLACE. Every satisfying segment with an attributable
549
+ * target — `seg.effectiveTarget !== null`, whether it came from the
550
+ * segment's OWN explicit repo-relocating flag (`-C`/`env -C`/`--git-dir`;
551
+ * `--work-tree` is excluded from ever producing a target at all, D-017)
552
+ * or was INHERITED from a preceding `cd` — demands the cwd context AND
553
+ * that target's own context, side by side; `seg.ownTarget` is not
554
+ * consulted for this decision. Any unsatisfied context blocks the whole
555
+ * event (the existing per-context evaluation machinery below is
556
+ * unchanged). `seg.effectiveTarget === null` (fully unattributable —
557
+ * D-003) still adds ONLY the cwd context — there is no foreign target to
558
+ * be additive WITH.
559
+ *
560
+ * Demanding cwd unconditionally makes the engine structurally immune to
561
+ * misattribution of the foreign target: no gap in `command-normalize.ts`'s
562
+ * static, string-only model of shell control flow (subshells, pipes,
563
+ * `cd -`, a later `cd`, an unmodeled git flag composition) can ever make a
564
+ * gate WEAKER than the shipped, cwd-only engine, because cwd's own demand
565
+ * is never dropped — only added to. This replaces an earlier REPLACE-for-
566
+ * own-target design (D-011) that trusted a statically extracted own
567
+ * target as proof of "this one invocation operates there"; four
568
+ * independent review passes each measured a live bypass of that
569
+ * invariant against the shipped binary (a forged `.git/HEAD` directory
570
+ * reached through, respectively, a non-persisting `cd`, `--work-tree`,
571
+ * more than one repo-relocating flag, and a tilde-valued flag not counted
572
+ * by the multi-flag guard) — see `03-decisions.md` D-011/D-017/D-018/
573
+ * D-019/D-020/D-021 for the full history. The static-analysis-proves-it
574
+ * invariant those bypasses each disproved is not carried forward here.
575
+ * Cost accepted by the operator: a legitimate `git -C B` from cwd A now
576
+ * demands BOTH A's and B's context, where the pre-D-021 engine demanded
577
+ * only B's.
578
+ *
579
+ * D-012: a target reached through a symlink resolves to its REAL
580
+ * (realpath'd) repository identity, not the symlink's own lexical
581
+ * basename — `resolveGitContext` derives `repo` from the basename of
582
+ * wherever it's pointed, and never itself realpaths, so an agent could
583
+ * otherwise pick the demanded repo identity by naming a symlink.
584
+ *
585
+ * D-013: distinct contexts are bounded at `MAX_ATTRIBUTED_CONTEXTS`; see
586
+ * that constant's own comment.
587
+ *
588
+ * D-015: the cwd context is deduped by its OWN `[REPO, BRANCH, sha]`
589
+ * signature (not the literal string `"cwd"`), so a foreign path that
590
+ * resolves to the SAME repository identity as cwd (e.g. a subdirectory of
591
+ * the cwd repo reached via an explicit `-C`) collapses into the cwd
592
+ * context instead of producing a spurious duplicate decision/audit write.
593
+ * `repoOverridden` / `branchOverridden` (from `InterceptOptions`, both
594
+ * default `false`) keep an operator's `HARNESS_REPO`/`HARNESS_BRANCH`
595
+ * override intact in an attributed foreign context instead of letting the
596
+ * target repo's own identity silently overwrite it — independently per
597
+ * field. `satisfying` is a `Set` (not `Array.includes` per segment) for
598
+ * O(1) membership tests instead of O(n) per segment.
599
+ *
600
+ * A policy with no attributable foreign target (no `bash_match`, no
601
+ * individually-matching segment, only an amp-arm-only match, an
602
+ * unattributable composition, a target resolving to the SAME repository
603
+ * identity as cwd, or a target outside any git repo — D-003) is evaluated
604
+ * EXACTLY as it is today, with the EXACT SAME `options.builtins` object
605
+ * reference (no clone) when nothing attributed, keeping that the
606
+ * byte-identical common case.
607
+ *
608
+ * `resolveGitContextMemo` is a per-`intercept()`-call cache keyed by the
609
+ * REALPATH'D absolute path (never module-level state — no cross-event
610
+ * caching), so several policies (or several satisfying segments) naming
611
+ * the same foreign path within one event pay the `fs` cost once, not
612
+ * once per policy per segment.
613
+ */
614
+ function resolveAttributedContexts(policy, segments, cwdBuiltins, cwdCurrentHeadSha, resolveGitContextMemo, repoOverridden, branchOverridden) {
615
+ const cwdContext = { builtins: cwdBuiltins, currentHeadSha: cwdCurrentHeadSha };
616
+ const satisfying = new Set(attributeTriggerSegments(policy, segments));
617
+ if (satisfying.size === 0)
618
+ return { kind: "contexts", contexts: [cwdContext] };
619
+ const cwdSignature = [cwdBuiltins.REPO, cwdBuiltins.BRANCH, cwdCurrentHeadSha ?? ""].join("|");
620
+ const seenSignatures = new Set();
621
+ const contexts = [];
622
+ const addCwdOnce = () => {
623
+ if (seenSignatures.has(cwdSignature))
624
+ return;
625
+ seenSignatures.add(cwdSignature);
626
+ contexts.push(cwdContext);
627
+ };
628
+ const cwdReal = realpathOrSelf(cwdBuiltins.CWD);
629
+ for (const seg of segments) {
630
+ if (!satisfying.has(seg))
631
+ continue;
632
+ if (seg.effectiveTarget === null) {
633
+ // D-003: fully unattributable — cwd only, no foreign target to be
634
+ // additive with.
635
+ addCwdOnce();
636
+ continue;
637
+ }
638
+ // D-021 (UNIVERSAL-ADDITIVE, operator decision after the four-pass
639
+ // halt — see this function's own doc comment): the cwd context is
640
+ // demanded UNCONDITIONALLY here, regardless of whether the target came
641
+ // from the segment's own explicit flag or was inherited from a
642
+ // preceding `cd`. `seg.ownTarget` is no longer read for this decision.
643
+ addCwdOnce();
644
+ const resolvedLexical = path.resolve(cwdBuiltins.CWD, seg.effectiveTarget);
645
+ const resolved = realpathOrSelf(resolvedLexical);
646
+ if (resolved === cwdReal) {
647
+ addCwdOnce();
648
+ continue;
649
+ }
650
+ let gitCtx = resolveGitContextMemo.get(resolved);
651
+ if (gitCtx === undefined) {
652
+ gitCtx = resolveGitContext(resolved);
653
+ resolveGitContextMemo.set(resolved, gitCtx);
654
+ }
655
+ if (gitCtx.repo.length === 0) {
656
+ // D-003: outside any repo → cwd fallback, not a distinct context.
657
+ addCwdOnce();
658
+ continue;
659
+ }
660
+ const signature = [gitCtx.repo, gitCtx.branch, gitCtx.sha].join("|");
661
+ if (signature === cwdSignature) {
662
+ // D-015: a foreign target that resolves to cwd's own REAL identity
663
+ // (e.g. a subdirectory of the cwd repo reached via `-C`, a
664
+ // different literal path than `cwdBuiltins.CWD` itself but the SAME
665
+ // `.git`) collapses into the cwd context regardless of segment
666
+ // order — not just when a bare cwd-reading segment happened to add
667
+ // it first.
668
+ addCwdOnce();
669
+ continue;
670
+ }
671
+ if (seenSignatures.has(signature))
672
+ continue;
673
+ if (contexts.length >= MAX_ATTRIBUTED_CONTEXTS) {
674
+ // D-013: fail CLOSED — do not evaluate any of them, name the
675
+ // ambiguity instead. `contexts.length + 1` names "at least this
676
+ // many distinct targets", the count observed before bailing, not
677
+ // necessarily the final total (the loop stops here).
678
+ return { kind: "bounded", distinctCount: contexts.length + 1 };
679
+ }
680
+ seenSignatures.add(signature);
681
+ contexts.push({
682
+ builtins: {
683
+ ...cwdBuiltins,
684
+ REPO: repoOverridden ? cwdBuiltins.REPO : gitCtx.repo,
685
+ BRANCH: branchOverridden ? cwdBuiltins.BRANCH : gitCtx.branch,
686
+ },
687
+ currentHeadSha: gitCtx.sha.length > 0 ? gitCtx.sha : undefined,
688
+ });
689
+ }
690
+ // Defensive fallback: every satisfying segment existed but somehow none
691
+ // was added above (should not happen — every branch above adds either
692
+ // the cwd context or a foreign one). Never leave a matched, per-repo-
693
+ // builtins policy with zero contexts to evaluate against.
694
+ return { kind: "contexts", contexts: contexts.length > 0 ? contexts : [cwdContext] };
695
+ }
696
+ /**
697
+ * Lazily resolve the event's per-segment view for attribution, computed
698
+ * at most once per `intercept()` call (the caller, `intercept()` below,
699
+ * memoises the RESULT in its own `segmentsForAttribution` local — this
700
+ * function itself is called at most once per call already, but stays
701
+ * side-effect-free so that remains true regardless of caller changes).
702
+ * Prefers `options.commandSegmentsThunk` (D-015 fix round: the deferred,
703
+ * compute-only-if-needed seam — see its own doc comment), then the eager
704
+ * `options.commandSegments`, then falls back to computing it directly
705
+ * from the event's own command for standalone callers/tests that inject
706
+ * neither. `null` (truncated — see `segmentViewOf`) and "no Bash command
707
+ * on this event" both collapse to `[]`, the same "nothing to attribute,
708
+ * cwd builtins" shape as a policy with no `bash_match` at all.
709
+ */
710
+ function resolveCommandSegments(options) {
711
+ if (options.commandSegmentsThunk !== undefined)
712
+ return options.commandSegmentsThunk() ?? [];
713
+ if (options.commandSegments !== undefined)
714
+ return options.commandSegments ?? [];
715
+ const command = extractShellCommand(options.event);
716
+ if (command === null)
717
+ return [];
718
+ return segmentViewOf(command) ?? [];
719
+ }
720
+ /**
721
+ * Synthesise the single decision `intercept()` records for a policy whose
722
+ * distinct attributed contexts exceeded `MAX_ATTRIBUTED_CONTEXTS` (D-013).
723
+ * Routes through `outcomeForFailedRequires` — the SAME enforcement-to-
724
+ * outcome mapping every other "could not safely evaluate" branch in
725
+ * `evaluateOnePolicy` uses — rather than a hardcoded outcome, so a
726
+ * `block`-enforcement policy (the security-critical case: preflight/push/
727
+ * merge gates) genuinely denies, while a `warn`-enforcement policy warns
728
+ * instead of hard-blocking, consistent with how every other failed-
729
+ * evaluation branch in this module already respects the policy's own
730
+ * declared enforcement. No ledger query, no template substitution against
731
+ * a resolved `ledger_tag` (there is no single context to substitute one
732
+ * against) — `extractValues` is still computed from `trigger.extract`
733
+ * against the CWD builtins (cheap, pure, matches the `operator_only`
734
+ * short-circuit's own reasoning) so a `ux:`/`producers:` block on this
735
+ * policy can still render.
736
+ */
737
+ function boundedContextsDecision(policy, event, distinctCount, cwdBuiltins, evaluatedAt) {
738
+ const extract = evaluateExtract(policy.trigger.extract ?? {}, buildEventContext(event), cwdBuiltins);
739
+ return {
740
+ policyName: policy.name,
741
+ enforcement: policy.enforcement,
742
+ outcome: outcomeForFailedRequires(policy.enforcement),
743
+ reason: `ambiguous: this command names at least ${distinctCount} distinct repository targets for this policy, ` +
744
+ `exceeding the ${MAX_ATTRIBUTED_CONTEXTS}-context bound — refusing to evaluate all of them`,
745
+ extractValues: extract.values,
746
+ ledgerTag: "(bounded: too many distinct attributed targets — no context queried)",
747
+ evaluatedAt,
748
+ };
749
+ }
326
750
  export async function intercept(options) {
327
751
  const { manifest, event } = options;
328
752
  // The Risk Gate is active only when some policy declares a `when:`
@@ -351,8 +775,9 @@ export async function intercept(options) {
351
775
  const whenFallbackMap = new Map();
352
776
  const matching = [];
353
777
  for (const p of manifest.policies) {
354
- if (!policyMatchesEvent(p, event, options.normalizedCommand))
778
+ if (!policyMatchesEvent(p, event, options.normalizedCommand, options.ampNormalizedCommandThunk, options.quoteNormalizedCommandThunk)) {
355
779
  continue;
780
+ }
356
781
  if (p.when === undefined) {
357
782
  matching.push(p);
358
783
  continue;
@@ -364,34 +789,73 @@ export async function intercept(options) {
364
789
  matching.push(p);
365
790
  }
366
791
  }
792
+ // Per-policy attribution (task `98ad072f`, T-003): `segmentsForAttribution`
793
+ // is resolved at most once, lazily, only if some matching policy actually
794
+ // uses `${REPO}`/`${BRANCH}`/`at_head` — a manifest with none of those
795
+ // (every Phase 4/5/6-only manifest) never computes it.
796
+ // `resolveGitContextMemo` is this call's own, non-module-level cache
797
+ // (task constraint: no eager/global filesystem work) — see
798
+ // `resolveAttributedContexts`'s own comment.
799
+ let segmentsForAttribution;
800
+ const resolveGitContextMemo = new Map();
367
801
  const decisions = [];
368
802
  for (const policy of matching) {
369
- const base = await evaluateOnePolicy(policy, options);
370
- // Attach the per-event Risk Gate verdicts so `harness audit` /
371
- // `explain --trace` can replay the classification + environment
372
- // that the `when:` match was made against. Also carry the
373
- // unclassifiedFallback flag (M7) when the when: evaluation set it
374
- // to true; leave the field absent otherwise so decisions from
375
- // manifests without a `when:` policy stay byte-identical.
376
- const whenFallback = whenFallbackMap.get(policy.name);
377
- const decision = enriched
378
- ? {
379
- ...base,
380
- risk: enriched.risk,
381
- environment: enriched.environment,
382
- ...(whenFallback === true ? { whenUnclassifiedFallback: true } : {}),
803
+ const attributed = usesPerRepoBuiltins(policy)
804
+ ? resolveAttributedContexts(policy, (segmentsForAttribution ??= resolveCommandSegments(options)), options.builtins, options.currentHeadSha, resolveGitContextMemo, options.repoOverridden === true, options.branchOverridden === true)
805
+ : { kind: "contexts", contexts: [{ builtins: options.builtins, currentHeadSha: options.currentHeadSha }] };
806
+ if (attributed.kind === "bounded") {
807
+ // D-013: fail CLOSED without querying the ledger for any of the
808
+ // (too many) distinct targets — one synthetic decision, one audit
809
+ // write, then move on to the next policy. Ledger-query count for
810
+ // THIS policy stays at zero regardless of how many distinct targets
811
+ // the command actually names, instead of scaling with them.
812
+ const evaluatedAt = (options.now ?? new Date()).toISOString();
813
+ const decision = boundedContextsDecision(policy, event, attributed.distinctCount, options.builtins, evaluatedAt);
814
+ decisions.push(decision);
815
+ try {
816
+ await options.ledger.record(decision, resolveSessionId(event.session_id));
383
817
  }
384
- : base;
385
- decisions.push(decision);
386
- try {
387
- await options.ledger.record(decision, resolveSessionId(event.session_id));
818
+ catch (err) {
819
+ (options.stderr ?? process.stderr).write(`harness runtime intercept: audit-write failed for ${decision.policyName}: ${err instanceof Error ? err.message : String(err)}\n`);
820
+ }
821
+ continue;
388
822
  }
389
- catch (err) {
390
- // Audit-write failure must not block; the decision is still applied.
391
- // Surface the failure to stderr so a persistently-failing recorder
392
- // does not silently leave `harness audit` / `explain --trace` blind.
393
- // Goes to stderr to keep the stdout deny-JSON contract intact.
394
- (options.stderr ?? process.stderr).write(`harness runtime intercept: audit-write failed for ${decision.policyName}: ${err instanceof Error ? err.message : String(err)}\n`);
823
+ for (const context of attributed.contexts) {
824
+ // Same object reference as `options` on the (overwhelmingly common)
825
+ // single-cwd-context path — no clone, byte-identical to the
826
+ // pre-attribution call shape. Only a genuinely foreign context
827
+ // builds a shallow override.
828
+ const contextOptions = context.builtins === options.builtins &&
829
+ context.currentHeadSha === options.currentHeadSha
830
+ ? options
831
+ : { ...options, builtins: context.builtins, currentHeadSha: context.currentHeadSha };
832
+ const base = await evaluateOnePolicy(policy, contextOptions);
833
+ // Attach the per-event Risk Gate verdicts so `harness audit` /
834
+ // `explain --trace` can replay the classification + environment
835
+ // that the `when:` match was made against. Also carry the
836
+ // unclassifiedFallback flag (M7) when the when: evaluation set it
837
+ // to true; leave the field absent otherwise so decisions from
838
+ // manifests without a `when:` policy stay byte-identical.
839
+ const whenFallback = whenFallbackMap.get(policy.name);
840
+ const decision = enriched
841
+ ? {
842
+ ...base,
843
+ risk: enriched.risk,
844
+ environment: enriched.environment,
845
+ ...(whenFallback === true ? { whenUnclassifiedFallback: true } : {}),
846
+ }
847
+ : base;
848
+ decisions.push(decision);
849
+ try {
850
+ await options.ledger.record(decision, resolveSessionId(event.session_id));
851
+ }
852
+ catch (err) {
853
+ // Audit-write failure must not block; the decision is still applied.
854
+ // Surface the failure to stderr so a persistently-failing recorder
855
+ // does not silently leave `harness audit` / `explain --trace` blind.
856
+ // Goes to stderr to keep the stdout deny-JSON contract intact.
857
+ (options.stderr ?? process.stderr).write(`harness runtime intercept: audit-write failed for ${decision.policyName}: ${err instanceof Error ? err.message : String(err)}\n`);
858
+ }
395
859
  }
396
860
  }
397
861
  // First blocking decision wins the envelope. `deny` and
@@ -438,7 +902,35 @@ export async function intercept(options) {
438
902
  // `run:` is the canonical remedy surface and rendering both would
439
903
  // give the agent two different command suggestions.
440
904
  let reasonText;
441
- if (blockingPolicy?.ux) {
905
+ if (blocking.outcome === "deny-degraded") {
906
+ // Degraded-specific envelope (task f1aea826). Deliberately takes
907
+ // precedence over the policy's `ux:` surface: the operator-curated
908
+ // ux text describes the MISSING-evidence case ("run the producer,
909
+ // then retry"), which is actively misleading here — the evidence
910
+ // could not be READ, so producing the tag cannot unblock anything
911
+ // until the ledger is reachable again. The envelope names the real
912
+ // cause (the transport-level reason, sanitised — it embeds output
913
+ // from the grounding-mcp subprocess) and the posture, so nobody
914
+ // debugs a phantom missing tag.
915
+ //
916
+ // The `risk.degraded_fail_posture: fail_open` opt-out is
917
+ // deliberately NOT named here (review 2026-08-08, high finding):
918
+ // this text is fed back to the very agent that was just blocked,
919
+ // and nothing prevents that agent from editing the manifest — a
920
+ // deny that includes its own disable recipe is not a gate. The
921
+ // opt-out lives on operator surfaces only: the stderr diagnostic
922
+ // (`formatDecisionDiagnostic`), docs/risk-gate.md, and the OKF
923
+ // fail-posture matrix. Same reasoning as the producer-neutrality
924
+ // norm above and the operator-only framing of `harness pause`.
925
+ reasonText =
926
+ `${blocking.policyName}: required evidence could not be read ` +
927
+ `(evidence ledger degraded: ${sanitizeEnvelopeReason(blocking.reason)}). ` +
928
+ `This ${blocking.enforcement} policy fails closed while its evidence ` +
929
+ `source is unreadable; producing the required tag will not unblock it ` +
930
+ `until the ledger is reachable again. Ask your operator to check ` +
931
+ `grounding-mcp (harness doctor), then retry. Session: ${sessionId}.`;
932
+ }
933
+ else if (blockingPolicy?.ux) {
442
934
  // The ux surface is operator-curated plain language. The
443
935
  // unclassifiedFallback flag rides the audit record (recorded above
444
936
  // and surfaced by `harness audit` and `explain --trace`) so operators