@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,1254 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.45.0] - 2026-08-19
11
+
12
+ ### Internal
13
+
14
+ - The 4 grandfathered reverse-import exemptions (duration, extract,
15
+ expand-home, ledger-record) recorded as known debt in 0.44.0 are closed:
16
+ the utils moved to `src/io/` and the dependency-cruiser exemptions are
17
+ deleted (slice 4 of the structural-concentration work, task 61a37b25).
18
+ `src/io` stays deliberately unconstrained; a follow-up considers an
19
+ io-no-upward-imports rule.
20
+ - `src/policy-packs/builtin/understanding-before-execution-runtime.ts`
21
+ (formerly a single monolith) is now a 9-line re-export shim over 7
22
+ concern-scoped siblings under `understanding-before-execution/`
23
+ (`ledger.ts`, `markers.ts`, `persisted-reports.ts`, `lifecycle.ts`,
24
+ `task-markers.ts`, `post-tool-use-boundary.ts`, `active-claim.ts`),
25
+ composed by `index.ts` (slice 2 of the structural-concentration work,
26
+ task `348a4d42`). Pure move: every existing import of the old path
27
+ (hook-pre-tool-use.ts, hook-codex-pre-tool-use.ts,
28
+ cli/approve/understanding.ts, tests, etc.) keeps working unchanged.
29
+ `safeJsonParse` (module-private in the monolith) is now exported from
30
+ `persisted-reports.ts` so `markers.ts` can import it cross-file, but is
31
+ deliberately NOT re-exported from the shim's public surface (pinned by
32
+ `tests/policy-packs/ube-export-surface.test.ts`); a follow-up considers
33
+ moving it into `src/io/`.
34
+ - `approval_lifecycle.expire_on_bash_match`'s shipped `SOLO_TEMPLATE` /
35
+ `TEAM_TEMPLATE` / `FULL_TEMPLATE` regexes stay `^`-anchored
36
+ (`^gh pr (merge|close)\b`, `^git push origin (master|main)\b`) — no
37
+ functional change from the last release (task `fb80b5bb`). A same-task
38
+ first attempt widened both patterns to `\b`-scoped/un-anchored to close
39
+ a measured fail-open gap (a boundary command behind `cd <dir> &&`, an
40
+ env-var prefix, a subshell, or an inserted flag never expired the
41
+ marker); a follow-up review measured that widening end-to-end against
42
+ the real PostToolUse hook and found it made things worse: the
43
+ `harness approve understanding <<'RPT' ... RPT` flow became
44
+ self-revoking (a report body that legitimately quotes a boundary
45
+ command as part of the plan expired its own freshly-written marker,
46
+ an operator-only recovery), plus at least 8 everyday false positives
47
+ observed during the round-2 review probe (corpus not retained;
48
+ `grep`/`echo`/commit-message text) and at least 20 residual fail-open
49
+ forms still observed uncaught in that same probe. A scoped
50
+ `isEscapeCommand` exemption for the
51
+ approve-heredoc was considered and rejected: it would import a
52
+ documented divergence class (4 prior halts) into a new consumer.
53
+ Reverted `src/cli/init/profiles.ts`, `src/cli/init/templates.ts`,
54
+ `docs/examples/full-manifest.yaml`, and regenerated
55
+ `docs/examples/full-manifest.expected.yaml` (verified byte-for-byte
56
+ against the real `describe()` output) back to the shipped patterns.
57
+ New docs: "expire_on_bash_match: start-anchored, with a documented
58
+ fail-open limitation" in
59
+ `docs/policy-packs/understanding-before-execution.md`, naming the full
60
+ set of known fail-open forms (including `git push --force/-u origin
61
+ main`, `git push origin HEAD:main`, `git -c x=y push`, `gh --repo o/r
62
+ pr merge`, whitespace variants), the safety-net role of `max_age`
63
+ (`1h` SOLO / `4h` TEAM+FULL), and a separate known gap: the interactive
64
+ custom profile (`harness init --interactive`, `composeCustom()` in
65
+ `src/cli/init/composer.ts`) sets `expire_on_tool_match` and `max_age`
66
+ but never sets `expire_on_bash_match` at all — tracked as a follow-up
67
+ (task `90eae119-cb77-4941-975c-7d2930e685d8`), not fixed here. Updated
68
+ `tests/cli/init-full-template-pins.test.ts`:
69
+ restored the "expire_on_bash_match is a separate anchored family,
70
+ untouched" guard's original `^`-anchored assertions and added a
71
+ trailing-`\b` structural check that the pre-fb80b5bb version of this
72
+ test never had (a round-2 mutation probe proved its absence would go
73
+ undetected); added an "anchored-pattern behavior" describe block
74
+ pinning positive matches, the documented known-miss forms, and
75
+ negative false-positive-avoidance forms (including the approve-heredoc
76
+ shape) against the actual shipped regexes; hoisted the `bashMatchers`
77
+ helper (duplicated across two describe blocks during this task's first
78
+ attempt on this branch — master carried only one definition) to module
79
+ scope. Mutation-verified: removing the `^` anchor from a pattern turns
80
+ both the structural guard and the false-positive-avoidance pins red;
81
+ removing only the trailing `\b` turns solely the new end-anchor check
82
+ red — both restored to green afterward. Round 2b: the "separate
83
+ anchored family, untouched" guard gained a whole-value
84
+ `toEqual([...])` pin over the two literals (the pre-existing
85
+ per-pattern shape checks stayed green under a widening such as
86
+ `(master|main)` -> `(master|main|develop)`); the known-miss `it.each`
87
+ array gained the 4 forms the docs already named but the array did not
88
+ yet pin (`git -c user.name=x push origin main`, `git push origin
89
+ HEAD:main`, `gh --repo owner/repo pr merge 42`, doubled whitespace);
90
+ and `tests/cli/init-composer.test.ts` gained a pin for the
91
+ interactive-composer gap named above (task
92
+ `90eae119-cb77-4941-975c-7d2930e685d8`).
93
+
94
+ ### Added
95
+
96
+ - `generateSettingsWithWarnings` (`src/cli/apply/generate-settings.ts`)
97
+ gains `projectSigningKeyEnv`, projecting the harness's own approval-
98
+ signing key path (`<generatedDir>/.approval-signing.key`,
99
+ `signingKeyPathFor`) as the `SOLUTION_VERDICT_SIGNING_KEY` env on the
100
+ `tools.mcp[grounding-mcp]` entry, exactly mirroring the existing
101
+ `EVIDENCE_LEDGER_DB` / `projectGroundingEnv` projection (task
102
+ `03a917fd`/H1, agent-grounding `9b6c4beb` comment 2, Option 2): only
103
+ the fully-resolved, absolute key PATH is projected, never the key
104
+ bytes; an operator-declared `env.SOLUTION_VERDICT_SIGNING_KEY` on the
105
+ entry wins; no projection fires without a grounding-mcp entry or
106
+ without the new `GenerateSettingsExtras.generatedDir` passed in
107
+ (there is no safe default to guess). Wiring the real, resolved
108
+ `generatedDir` through `harness apply`'s and `harness init`'s call
109
+ sites (task `03a917fd`/H1b) is now live: `generate-opencode-config.ts`'s
110
+ `generateOpencodeConfig` gained the matching `generatedDir` extra and
111
+ `apply.ts`'s `buildExpectedFiles` threads its own resolved
112
+ `generatedDir` into both that call and its `generateSettingsWithWarnings`
113
+ call; `init/interactive.ts`'s `loadDesiredMcpServers` — the function
114
+ that feeds the REAL `claude mcp` CLI registration (`ensureMcpServers`)
115
+ — now also projects `SOLUTION_VERDICT_SIGNING_KEY`, mirroring
116
+ `EVIDENCE_LEDGER_DB` there; `adopt/derive.ts`'s `manifestMcpProjection`
117
+ gained the matching manifest-side mirror so an apply→adopt cycle does
118
+ not report phantom drift on grounding-mcp for this key either. One
119
+ caveat carried over from `EVIDENCE_LEDGER_DB`/T-002: settings.json's
120
+ `mcpServers` block (and therefore `apply.ts`'s
121
+ `generateSettingsWithWarnings` call specifically) is dead at runtime —
122
+ Claude Code's real MCP registration goes exclusively through the
123
+ `claude mcp` CLI path in `interactive.ts`, so the settings.json branch's
124
+ `generatedDir` threading is wired for symmetry/future-proofing but has
125
+ no independently observable effect today.
126
+ - Review round H1 fix-round (task `03a917fd`): `generate-settings.ts`
127
+ gains `buildDesiredMcpServers(manifest, {homeDir, generatedDir})`, a
128
+ single choke-point wrapper around `buildMcpServers` ->
129
+ `projectGroundingEnv` -> `projectSigningKeyEnv`. Every producer of the
130
+ Claude-Code-shaped MCP server-spec map — `harness apply`'s
131
+ settings.json generation, `init --interactive`'s `claude mcp` CLI
132
+ wiring, `harness doctor`'s registration-health check, and the opencode
133
+ config generator — now calls this one function instead of hand-rolling
134
+ the same three-call sequence, closing the gap where doctor's "not
135
+ registered, run this `claude mcp add-json ...`" hint for grounding-mcp
136
+ silently omitted `SOLUTION_VERDICT_SIGNING_KEY` (3 of the 4 producers
137
+ called `projectSigningKeyEnv`, doctor did not). `projectSigningKeyEnv`
138
+ also normalizes a relative or literal-tilde `generatedDir`
139
+ (`expandHome` + `path.resolve`, mirroring `groundingLedgerEnvValue`'s
140
+ idiom for `EVIDENCE_LEDGER_DB`) into an absolute path instead of
141
+ silently under-projecting. `docs/ARCHITECTURE.md`'s `grounding:`
142
+ wiring-status note and `GenerateSettingsResult.mcpServers`'s doc
143
+ comment now name both projections.
144
+ - `harness doctor` gains an on-demand toolchain-parity section (task
145
+ `13919613`, commit `16d47941`), the doctor companion to `harness session-start
146
+ toolchain-parity`: reuses that command's Collector/Comparator core
147
+ (`collectLocalSnapshot`, `compareToPeer`, `parseSnapshotJson`,
148
+ `formatSnapshotAge`, the real collectors) verbatim — no comparison
149
+ logic is duplicated — to compare the current live toolchain against
150
+ whatever peer snapshots already exist in `machine_state_dir`, without
151
+ ever writing a snapshot file or a ledger fact. Present only when
152
+ `toolchain_parity.enabled: true` (omitted otherwise), following the
153
+ same optional-section convention as `npmGlobalBin`/the claude-code MCP
154
+ registration check. `--shallow` skips the live collection (no
155
+ `node`/`npm` spawn), mirroring those same checks. Drift always rolls
156
+ into `warningCount`, never `errorCount` — advisory only, not a gate,
157
+ same contract as the SessionStart producer. Documented in
158
+ `docs/CLI.md`.
159
+ - `harness session-start toolchain-parity` hardening round (task
160
+ `c1b5ade5`, follow-up to the initial 690fba7c drop): (1) a lossy
161
+ `sanitizeProfileName` advisory — a configured `profile` that is not
162
+ filename-safe now logs a warning, plus a stronger WARNING when the
163
+ sanitized filename collides with an existing snapshot for a DIFFERENT
164
+ profile (this run's write is about to overwrite it); (2) defensive
165
+ try/catch around the `resolveSession` and `writeLedger` call sites — a
166
+ throw/rejection from either now degrades to a note + exit 0 instead of
167
+ an unhandled throw/rejection, same as every other failure path in this
168
+ producer (deliberately NOT applied to `collectLocalSnapshot`, which the
169
+ hermetic-spawn-guard contract requires to propagate uncaught); (3) a
170
+ new optional `toolchain_parity.stale_after_days` manifest knob
171
+ (`src/schema/toolchain-parity.ts`, default off) — a peer snapshot older
172
+ than the threshold gets an extra advisory note without being counted
173
+ as drift. Advisory character is unchanged throughout: every PRODUCTION
174
+ path exits 0 (an injected collector that throws propagates by the
175
+ hermetic-spawn-guard contract, corrected wording — see Round 2b below),
176
+ and `ok`/drift comparator semantics are untouched. Also
177
+ corrects the `toolchain-parity` hook's `budget_ms: 10000` comment in
178
+ `src/cli/init/templates.ts`, which previously described only the
179
+ parallel node/npm collection and missed that the ledger write runs
180
+ SEQUENTIALLY after with its own ~5s default timeout floor (worst case
181
+ ~9s, not the "comfortable" sub-5s headroom the old comment claimed).
182
+ **Round 2 (review fix-round, same task):** (a) the collision-warning
183
+ note and the stale-peer note both interpolated an untrusted,
184
+ cross-machine-synced `profile` field into the stderr stream
185
+ unsanitized — a crafted profile string (embedded newline plus
186
+ note-prefix-shaped text) could forge a standalone parity line;
187
+ both sites now wrap the value in `sanitizeProfileName(...)`, same as
188
+ the pre-existing filename-label site (the two "ok against"/"drift:N
189
+ against" sibling notes stay unsanitized — a known, separate gap,
190
+ scoped out of this round); (b) the collision check was one-sided — it
191
+ only ran on the lossy machine's own run, so a filename-safe machine
192
+ silently overwrote a lossy peer's snapshot with no signal and that
193
+ peer silently dropping out of `peersCompared`; the existing-file read
194
+ now runs on every invocation, and only the "not filename-safe" note
195
+ stays scoped to the lossy case; (c) a session resolver that throws
196
+ while the stdin event DID carry a `session_id` used to still report
197
+ `sessionSource: "stdin"` even though `sessionId` had actually fallen
198
+ back to `"default"` — misrepresenting the ledger fact's provenance and
199
+ suppressing the AC5 "resolved to default" warning; a `resolverThrew`
200
+ flag now short-circuits `sessionSource` to `"default"` whenever the
201
+ catch fires; (d) `stale_after_days` gained `.finite()` — `.positive()`
202
+ alone accepted `Infinity` (reachable via YAML `.inf`), silently turning
203
+ the staleness check into a permanent no-op. The try/catch this round
204
+ removes was around `resolveManifestLedgerWriter`: unlike the two
205
+ real I/O/spawn-adjacent seams it sat next to (`resolveSession`,
206
+ `writeLedger`), that call has no throw path at all — it is a pure
207
+ function over an already-validated `manifest` (a plain array `.find`
208
+ plus a string split underneath) — so the catch was dead, untested code
209
+ (round-1 review finding) and is removed rather than built out into a
210
+ fake seam; the two genuine I/O sites stay caught.
211
+ **Round 2b (second review fix-round, same task, `c1b5ade5`):** R2's
212
+ review, re-run against the R2 fix, proved the log-injection class was
213
+ still open across roughly ten call sites in three vector families — R2
214
+ had sanitized only the collision-warning and stale-peer notes, leaving
215
+ every other note() call reachable by untrusted content: the "ok
216
+ against"/"drift:N against" notes (raw `comparison.peerProfile`, never
217
+ wrapped by R2 at all), every individual drift message from
218
+ `compareToPeer` (untrusted `peer.node`/`owKitVersion`/npm/mcp names,
219
+ reproduced against a real forged `parity:unparseable-peer:INJECTED` tag),
220
+ and two of the three peer-file-read notes (the corrupt-file and
221
+ unreadable-file lines used a raw `fileName`, while only the third,
222
+ `peerLabel`, was sanitized). Fix: moved the CR/LF strip to the shared
223
+ `note()` closure itself — `msg.replace(/[\r\n]/g, " ")`, once, before
224
+ every stderr write — closing the whole class at its one choke point
225
+ instead of requiring every present and future call site to remember its
226
+ own sanitization. With injection protection centralized in `note()`, the
227
+ three R2-added `sanitizeProfileName(...)` display wraps (collision note,
228
+ unparseable-peer `peerLabel`, stale-peer note) are reverted back to the
229
+ raw field: `sanitizeProfileName` is a FILENAME escaper (documented at
230
+ its definition) that also lossily mangles a legitimate non-ASCII profile
231
+ name, so reusing it for display was both redundant once `note()`
232
+ protects the line and a correctness regression — the stale-peer note's
233
+ sanitized spelling could differ from the same peer's raw spelling on the
234
+ "ok against" line two lines above it, within the SAME run. `note()`
235
+ itself is now the only site responsible for log-injection safety; the
236
+ filename path (`ownFileName`/`writeOwnSnapshot`) is unaffected and still
237
+ goes through `sanitizeProfileName`. Also, three unrelated LOW findings
238
+ from the same review pass: (a) the collision-warning note now appends a
239
+ one-line remediation hint ("...set a distinct `toolchain_parity.profile`
240
+ on one of the machines"); (b) the collision pre-read's previously-bare
241
+ `catch {}` is narrowed to re-note any error whose `code` is not `ENOENT`
242
+ (a genuinely missing file is still silent — EACCES/EISDIR/etc. is now a
243
+ one-line note that the collision check silently did not run, rather than
244
+ vanishing into the same branch as "no existing file"); (c) the ledger
245
+ write's `result.reason ?? "unknown error"` fallback is kept, with a
246
+ comment explaining why (`AddLedgerFactResult`'s `!ok` arm types `reason`
247
+ as non-optional, so the fallback is unreachable through any type-checked
248
+ caller — but unlike the pure `resolveManifestLedgerWriter` call the R2
249
+ catch-removal above targeted, `writeLedger` is an injectable *public*
250
+ seam a JS-level or otherwise untyped caller can reach with a malformed
251
+ return value at runtime, so this one stays). New/updated tests: a
252
+ note()-level pin (an arbitrary thrown message with an embedded newline
253
+ produces no second stderr line), dedicated regressions for the three
254
+ reviewer-reproduced vectors (hostile `peer.profile`, hostile `peer.node`
255
+ in a drift message, hostile peer filename with an embedded newline —
256
+ each asserted against the exact greppable prefix it tried to forge), a
257
+ negative control pinning that the collision check does NOT fire when a
258
+ machine's own profile matches its own existing snapshot (guards a
259
+ "warn-always" mutation), an EISDIR case for the narrowed catch, and a
260
+ label-coherence test showing one peer's name renders identically on the
261
+ "ok against" and stale-peer lines in the same run.
262
+
263
+ ### Security
264
+
265
+ - No functional change: the known fail-open limitation of
266
+ `approval_lifecycle.expire_on_bash_match` is now documented — see the
267
+ Internal entry above (task `fb80b5bb`) and
268
+ `docs/policy-packs/understanding-before-execution.md`.
269
+ - **SECURITY: `harness session-start toolchain-parity`'s stderr log-injection
270
+ class is closed, across every call site, not just the two R2 patched**
271
+ (task `c1b5ade5` R2b). The machine-state directory is populated
272
+ cross-machine by `agent-memory-sync`, so a peer's snapshot content
273
+ (`profile`, `node`, `owKitVersion`, npm/mcp package names) and even a
274
+ peer's raw filename are all attacker-influenced input; a crafted value
275
+ containing an embedded newline plus note-prefix-shaped text could forge
276
+ a standalone, fully fake `harness session-start toolchain-parity: ...`
277
+ line in stderr — including the exact greppable
278
+ `parity:unparseable-peer:<label>` / `toolchain-parity:ok` /
279
+ `toolchain-parity:drift:<n>` tags an operator or `harness audit` would
280
+ grep for. R2 (same task, prior round) sanitized only 2 of the roughly 10
281
+ reachable sites (the collision-warning and stale-peer notes); this round
282
+ closes the rest — the "ok against"/"drift:N against" notes, every
283
+ individual `compareToPeer` drift message, and the two previously-raw
284
+ peer-file-read notes — by stripping CR/LF once, in the shared `note()`
285
+ closure all of these funnel through, rather than continuing to require a
286
+ wrap at each call site. The pre-existing 876/878-area peer-unreadable and
287
+ peer-corrupt notes (raw `fileName`) are covered by this same fix; there
288
+ is no remaining open site in this class and no follow-up is needed.
289
+ - **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME trade-off f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery.
290
+ - **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
291
+ - New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, cross-id signature-replay rejection, signing-key-I/O-failure-not-classified-as-forged, and the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper regressions exercised through the real hook entrypoint).
292
+ - **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME strict no-grace-period POLICY f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery. (The RECOVERY shape differs, though — see the honest-residual bullet below.)
293
+ - **Fix-round-2 (review R1 HIGH): cross-id replay via a verbatim file copy is now closed.** `verifyVerdictSignature` previously derived the HMAC markerId from `verdict.id` — a field read back out of the marker BODY, which an attacker controls simply by copying a validly-signed verdict file onto a DIFFERENT id's marker path (the body's `id` travels with the copy, unchanged). `evaluateGate` never compared `verdict.id` against the id it was actually checking, so a verbatim copy of one task's ready verdict onto a second task's path passed verification and ALLOWED the second task to complete. `verifyVerdictSignature` now takes the CALLER's id explicitly and derives the markerId from it, matching the load-bearing invariant `approval-signing.ts` already documents ("verification always recomputes the HMAC using the id the caller is checking, NOT one read back out of the marker body"); `evaluateGate` additionally rejects outright when `verdict.id !== id` (belt-and-braces — the signed payload does not cover `id` itself, so this catches a post-signing edit to that one field that the markerId fix alone would not).
294
+ - **Fix-round-2 (review R1 LOW): the hook's STDERR diagnostic now consumes `GateResult.forged`.** `harness pack hook solution-acceptance` appends a short `[audit: forged/unsigned verdict marker rejected]` tag to its operator-facing diagnostic line specifically when `forged: true`, mirroring the `markerForgedNote` audit-tag pattern already used by `hook-pre-tool-use.ts` / `hook-branch-protection.ts` — previously `GateResult.forged` was computed but never read by any production code path.
295
+ - **Fix-round-2 (review R1 LOW): a verdict missing `timestamp` or `source` is no longer misclassified as forged.** Those two fields map onto `verifyMarkerSignature`'s `approvedAt` / `approvedBy`; a blank one is a legitimately malformed marker (e.g. a producer bug), not necessarily an active forgery attempt. `evaluateGate` now carves the `"missing approvedAt"` / `"missing approvedBy"` verification reasons out of the generic forged bucket — fail-closed (`allowed: false`) either way, but `forged: false`, the same shape as the existing signing-key-unavailable carve-out.
296
+ - **Fix-round-2b (review R2 MED, audit-fidelity): the missing-timestamp/missing-source carve-out above is now scoped to verdicts with NO signature at all.** As shipped, the carve-out fired on the verification REASON string alone, regardless of whether the verdict actually carried `alg`/`signature` fields — so a forger who mutated an otherwise VALIDLY-SIGNED verdict to blank its `timestamp` (or `source`) suppressed the `forged: true` audit tag entirely: `verifyMarkerSignature` checks `approvedAt`/`approvedBy` for blankness BEFORE ever comparing the signature, so the blanked-but-signed marker hit the same `"missing approvedAt"` reason as a genuinely never-signed one and inherited its `forged: false` classification. The block itself still held either way (`allowed: false` throughout — this was never a bypass), but the audit trail lied about what happened. `evaluateGate` now additionally requires `verdict.signature === undefined && verdict.alg === undefined` before granting the carve-out; a verdict that carries `alg`/`signature` but still hits the blank-field reason falls through to the generic `forged: true` bucket instead, since a signed field reading blank only happens via post-signing tampering. The genuinely-unsigned case (no `alg`/`signature` fields, blank `timestamp`/`source`) keeps its `forged: false` classification unchanged — that one remains a defensible "unsigned legacy marker" read, not an attack signal.
297
+ - **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. **Unlike f9485cc7's residual, there is no operator-side command that resolves this one**: f9485cc7's producer and consumer live in this one repo, so a denied operator has a local one-command fix (`harness approve understanding` / `harness approve branch-protection`); here the producer is a separate package on its own release cadence, so nothing an operator runs today makes a verdict pass until that producer release ships. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
298
+ - New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, relabeled-body signature-replay rejection, a VERBATIM file-copy cross-id replay regression, an isolated `verdict.id !== id` pin independent of the markerId fix, a sanitize-collision regression (`"a:b"` vs `"a_b"` colliding on one file path), the two missing-timestamp/missing-source not-forged carve-outs (now exercised via a genuinely UNSIGNED marker), signing-key-I/O-failure-not-classified-as-forged, the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly, and — fix-round-2b — a SIGNED-verdict-with-blanked-timestamp/source regression pinning `forged: true` (audit finding A8) plus a minimal-hand-written-marker negative control pinning `forged: false` (audit finding A7)) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper/cross-id-replay regressions and the forged-audit-tag diagnostic, exercised through the real hook entrypoint, plus — fix-round-2b — the SIGNED-and-blanked-timestamp forged-tag regression, the `verdict.id !== id` cross-id-mismatch-with-a-still-valid-signature regression, and forged-tag negative controls on the not-ready and stale-head denial paths).
299
+ - **SECURITY (LOW): `isEscapeCommand`'s top-level `command.trim()`
300
+ (`src/cli/pack/approve-escape.ts`) stripped a TRAILING report-heredoc line
301
+ made only of a non-bash-blank whitespace codepoint (e.g. NBSP, U+2028)
302
+ before the 623640a5 `after.every(/^[ \t]*$/)` post-terminator check ever
303
+ saw it, making that check unreachable through the public API for exactly
304
+ the shape it exists to reject** (task `508a2d81`, a residual the
305
+ 623640a5 fold self-disclosed). PATH-stub verified against real bash: such
306
+ a line is not inert — bash attempts to execute it as a real command.
307
+ Fix: the top-level trim now strips only TAB, SPACE, LF, and CR from the
308
+ string's boundaries (never the full JS `\s`/Unicode-whitespace class), so
309
+ every other `\s` codepoint survives to the after-check.
310
+ - **SECURITY: the agent-facing deny envelope of the built-in `gate-prod-destructive`
311
+ Risk Gate policy named the session-wide kill switch (`harness pause --for
312
+ <duration>` / `harness resume`) as a way to unblock a denied critical
313
+ production mutation** (task `70781cf6`). `ux.run` is the exact "what to do
314
+ next" text the blocked agent reads (`renderAgentFacing`,
315
+ `src/runtime/intercept.ts`); handing that same agent a recipe for silencing
316
+ every gate in the session is a full bypass instruction addressed to the
317
+ party who was just blocked, not to the operator who alone can act on it.
318
+ Removed the kill-switch line from `gate-prod-destructive`'s `ux.run` in both
319
+ `src/cli/init/templates.ts` (`FULL_TEMPLATE`) and
320
+ `docs/examples/full-manifest.yaml`; the two remaining `run:` entries (choose
321
+ a non-destructive alternative, or ask the operator for a deliberate `harness
322
+ approve risk --force` override) stay unchanged and fully actionable. The
323
+ operator-facing recovery path is not lost: `harness pause`'s own refusal
324
+ message already explains the correct route (a terminal genuinely outside the
325
+ agent session). While auditing every other shipped `ux.run` line for the
326
+ same class of leak, the `deny-kill-switch-bypass` and
327
+ `deny-pause-sentinel-forgery` policies' `run:` text also named `pause`/
328
+ `resume` as something the operator could be asked to do; reworded both to
329
+ point back at the commands already named in their `cannot`/`required` text
330
+ instead of repeating the verbs in the actionable `run:` list.
331
+ `deny-session-env-strip`'s `run:` line did not name either verb and is
332
+ unchanged. Fix round 1 (Finding 3, reviewer-flagged): reworded
333
+ `deny-pause-sentinel-forgery`'s `run:` line again — its first rewording's
334
+ "Ask the OPERATOR to do that themselves" had an ambiguous antecedent (the
335
+ nearest noun phrase was the just-forbidden sentinel write, not the
336
+ operator's actual recovery action); it now points explicitly at "the
337
+ operator-only command named under Required" instead, without
338
+ reintroducing `pause`/`resume` into the `run:` text itself.
339
+
340
+ New/updated tests: `tests/cli/init-no-agent-facing-pause-hint.test.ts` is
341
+ a repo-wide guard over every agent-facing `ux.run` line the init/describe
342
+ surface can emit: `manifest.policies[].ux.run` AND
343
+ `manifest.policy_packs[].config.ux.run` (understanding-before-execution,
344
+ branch-protection, post-merge-gate) across `FULL_TEMPLATE`,
345
+ `SOLO_TEMPLATE`, `TEAM_TEMPLATE`, and `docs/examples/full-manifest.yaml`,
346
+ plus the Custom composer (`composeCustom()` in
347
+ `src/cli/init/composer.ts`, `harness init --interactive`'s Custom
348
+ profile) with every composable pack and policy selected. Fix round 1
349
+ (Finding 1, reviewer-flagged as a MEDIUM coverage gap) added the
350
+ `policy_packs`/composer coverage and the `harness gate disable`/`harness
351
+ gate enable` sibling-verb check — the original guard only walked
352
+ `manifest.policies[].ux.run`, so a kill-switch hint reworded into a
353
+ policy pack's `ux.run` (or into the Custom composer's own copy) would
354
+ have passed both this guard and the pre-existing parity tests
355
+ unnoticed. Each scanned source also now asserts it found at least one
356
+ `ux.run` line (Finding 2): before the round-1 fix, `SOLO_TEMPLATE` has
357
+ zero top-level `policies:` entries, so that arm silently scanned zero
358
+ lines under the original narrower guard and could not have caught
359
+ anything. A "see also" citation to
360
+ `docs/okf/pause-vs-gate-kill-switch.md` stays allow-listed as
361
+ non-actionable, and a dedicated assertion pins that
362
+ `gate-prod-destructive`'s `ux.run` has exactly its two legitimate
363
+ entries left. Regenerated `docs/examples/full-manifest.expected.yaml`
364
+ (the `harness describe` golden fixture) to match. Coverage note
365
+ (Finding 4, reviewer-flagged as inaccurate): the existing
366
+ `tests/cli/init-full-template-parity.test.ts` is the test that
367
+ deep-equality-checks `FULL_TEMPLATE` against
368
+ `docs/examples/full-manifest.yaml` (including each policy_pack's
369
+ `config`, so its `ux.run` too); `tests/cli/init-templates-ux-parity.test.ts`
370
+ is a separate drift guard that instead pins `FULL_TEMPLATE` /
371
+ `SOLO_TEMPLATE` / `TEAM_TEMPLATE` / the Custom composer's policy_pack
372
+ `ux` against the canonical `defaultUx()` functions in
373
+ `src/policy-packs/builtin/*.ts`, not against `full-manifest.yaml`.
374
+
375
+ - **SECURITY: `harness apply`'s generated Claude Code `settings.json` hook
376
+ `timeout` was emitted in the manifest's `budget_ms` unit unconverted, so
377
+ every Claude-hook kill-timer was 1000x too large** (task `7bf47554`).
378
+ Claude Code's settings.json hook `timeout` is documented in SECONDS
379
+ ("Seconds before canceling",
380
+ https://code.claude.com/docs/en/hooks); the manifest's `budget_ms` is
381
+ milliseconds. `toSettingsCommand` (`src/cli/apply/generate-settings.ts`)
382
+ emitted `timeout: h.budget_ms` with no unit conversion, so a template
383
+ budget of 1000-2000ms silently became a Claude Code timeout of
384
+ 1000-2000 SECONDS (16-33 minutes) instead of 1-2 seconds, meaning
385
+ hard-blocking enforcement hooks (e.g. `harness policy intercept`) could
386
+ hang for tens of minutes before Claude Code cancelled them, rather than
387
+ failing fast. The sibling Codex projection
388
+ (`src/cli/apply/generate-codex-config.ts`) already converted correctly
389
+ (`Math.ceil(budget_ms / 1000)` with a policy-intercept floor); the
390
+ Claude Code projection now shares that exact formula via a single
391
+ extracted helper, `hookTimeoutSeconds` (`generate-settings.ts`), so the
392
+ two runtime projections cannot diverge on this again. `harness adopt`'s
393
+ reverse projection (`src/cli/adopt/index.ts`'s `buildHookEntry`), which
394
+ read a live settings.json `timeout` back into the manifest's
395
+ `budget_ms`, is fixed symmetrically (`* 1000`) so the adopt<->apply
396
+ round-trip stays lossless; `timeout` remains deliberately excluded from
397
+ the adopt drift key, so the ms/seconds rounding asymmetry a non-1000-
398
+ multiple `budget_ms` introduces (e.g. 1500ms -> 2s -> 2000ms) does not
399
+ register as phantom drift or duplicate-adopt a hook. New/updated tests:
400
+ `tests/cli/apply/generate-settings.test.ts` pins the corrected
401
+ conversion (including the 2s/1s floors) and asserts both runtime
402
+ projections emit the identical seconds value for the same hook;
403
+ `tests/cli/adopt.test.ts` adds an explicit apply-then-adopt round-trip
404
+ test for the 1500ms rounding-asymmetry case, asserting zero drift.
405
+
406
+ **Fix round 2, same task: the corrected 1-2s outer timeout above was
407
+ ITSELF a fail-open regression, not purely a safety fix.** Review found
408
+ that `harness policy intercept`'s blocking, ledger-consulting hooks —
409
+ every `requires:`-based policy (the require-*-evidence / risk-gate
410
+ policies) AND, via the engine's unconditional per-decision audit write
411
+ (`intercept()`'s `options.ledger.record(...)` call, run for every
412
+ matched policy including the pure pattern-deny `operator_only` kill
413
+ switches), every other blocking `harness policy intercept` hook too —
414
+ can legitimately need up to the grounding-mcp server's own
415
+ `health.timeout_ms` (5000ms by default) for a single ledger query, plus
416
+ a further deny-degraded audit-write retry bounded by
417
+ `auditRetryTimeoutMs` (`src/cli/policy/intercept.ts`), for a measured
418
+ worst case of ~10.8-13.75s. The now-CORRECT 1-2s Claude Code outer
419
+ timeout sits well UNDER that: on a merely slow or hung ledger (not a
420
+ clean, fast failure), Claude Code kills the `harness policy intercept`
421
+ subprocess before its `deny`/`deny-degraded` JSON reaches stdout, and a
422
+ killed PreToolUse hook is treated as ALLOW — silently turning a
423
+ correctly-computed fail-closed verdict into an unintended fail-open one.
424
+ The same shape applies to the three builtin policy-pack blockers
425
+ (`branch-protection`, `understanding-before-execution`,
426
+ `post-merge-gate`), whose own ledger probes are bounded by the identical
427
+ `health.timeout_ms` with effectively zero margin against their own
428
+ budget_ms=5000 outer timeout. Fixed by raising every blocking (`blocking:
429
+ "hard"`), ledger-consulting hook's `budget_ms` to 15000 (a 15s outer
430
+ timeout) in `src/cli/init/templates.ts` (FULL_TEMPLATE's eleven
431
+ `harness policy intercept` hooks, uniformly — deliberately including the
432
+ three pattern-deny kill-switch hooks, both because their audit write
433
+ waits on the same ledger round-trip and because `generate-settings.ts`'s
434
+ `buildGroups` dedupes same-matcher hooks by `(command, timeout)`, so a
435
+ non-uniform budget would split one Claude Code invocation per Bash call
436
+ into several redundant ones), `docs/examples/full-manifest.yaml` /
437
+ `.expected.yaml` (the one hook there that actually routes through
438
+ `harness policy intercept`), and the three builtin policy packs
439
+ (`src/policy-packs/builtin/branch-protection.ts`,
440
+ `understanding-before-execution.ts` — both the Claude and Codex
441
+ variants — `post-merge-gate.ts`). 15000ms clears the measured
442
+ ~10.8-13.75s worst case with margin. Trade-off, stated plainly: a gated
443
+ action can now visibly freeze for up to ~15s when the evidence ledger is
444
+ down or hanging, instead of failing fast (and silently open) at 1-2s —
445
+ this IS the intended fail-closed posture the Risk Gate's
446
+ `degraded_fail_posture: preserve_enforcement` default (task `f1aea826`)
447
+ already commits to; the pre-existing 1-2s budget just never gave that
448
+ posture enough time to actually land. New test:
449
+ `tests/runtime/hook-budget-ledger-margin.test.ts` pins, against the real
450
+ shipped `FULL_TEMPLATE` and the real policy-pack `resolve()` output
451
+ (not a hardcoded hook-name list), that every blocking ledger-consulting
452
+ hook's outer timeout clears `health.timeout_ms` plus the deny-degraded
453
+ audit-retry margin — a lightweight stand-in for the fuller drift-guard
454
+ task `d20a7e0c` is tracking.
455
+
456
+ **Fix round 2 continued, same task: the raise above was itself
457
+ incomplete.** Review found two more `harness init` surfaces still
458
+ shipping the identical blocking `harness policy intercept` hooks at
459
+ their pre-fix 1000-2000ms budgets — the same fail-open gap, reachable
460
+ through a different init path than `--template full`: the
461
+ `harness init --interactive` Custom composer's `HOOK_FOR_POLICY` table
462
+ (`src/cli/init/composer.ts`, five hook rows backing its six reference
463
+ policies) and the `team` profile template's `require-review-evidence`
464
+ hook (`src/cli/init/profiles.ts`'s `TEAM_TEMPLATE`). Raised both to the
465
+ same uniform 15000ms. The `solo` profile template ships no blocking
466
+ `harness policy intercept` hook at all (no agent-tasks-coupled or
467
+ Bash-triggered evidence policy in that profile), so there is nothing to
468
+ raise there; a new negative-control test pins that this is by design,
469
+ not an oversight. With this, the "every blocking ledger-consulting
470
+ hook" framing above is now accurate across every manifest-emitting
471
+ `harness init` surface, not only `FULL_TEMPLATE`. Also strengthened
472
+ `tests/runtime/hook-budget-ledger-margin.test.ts`: it now asserts the
473
+ same margin invariant against the Custom composer's full policy
474
+ selection and `TEAM_TEMPLATE` too (mutation-probe confirmed: reverting
475
+ either surface's budget back to its pre-fix value turns exactly that
476
+ surface's new assertion red, while every other assertion — including
477
+ the other surface's — stays green), asserts the `SOLO_TEMPLATE`
478
+ negative control, and adds a hard 15000ms floor assertion alongside the
479
+ existing formula-derived margin check. The formula alone
480
+ (`health.timeout_ms` + 2x `auditRetryTimeoutMs`, ~8s for the shipped
481
+ 5000ms grounding-mcp timeout) understates the measured ~10.8-13.75s
482
+ subprocess wall-time worst case, so a partial regression into
483
+ [8000ms, 13000ms) would still pass a formula-only check; the new hard
484
+ floor (pinned to the same ~10.8-13.75s measurement this task's budget
485
+ note above and `src/cli/init/templates.ts` both cite) closes that gap,
486
+ with its own control test pinning the discrimination at a 10000ms
487
+ probe value.
488
+
489
+ - **SECURITY: `harness validate` / `harness doctor` now ENFORCE the
490
+ hook-budget-vs-ledger-timeout margin task `7bf47554` above only pinned
491
+ by hand against the shipped defaults** (task `d20a7e0c`). Everything
492
+ `7bf47554` fixed was a one-time, hardcoded raise of specific budgets on
493
+ specific manifest-emitting surfaces (`FULL_TEMPLATE`, the Custom
494
+ composer, `TEAM_TEMPLATE`, three builtin policy packs); nothing stopped
495
+ an aged manifest, a hand-authored `harness.yaml`, or a manifest that
496
+ simply raises `tools.mcp.grounding-mcp.health.timeout_ms` from silently
497
+ regressing back into the fail-open gap — a merely SLOW (not even
498
+ hard-down) evidence ledger getting a blocking `harness policy
499
+ intercept` / policy-pack hook killed by the runtime's outer hook
500
+ timeout before its fail-closed `deny`/`deny-degraded` JSON reaches
501
+ stdout, which both Claude Code and Codex then read as an unintended
502
+ ALLOW.
503
+
504
+ Two fixes:
505
+
506
+ 1. **Corrected margin formula.** `7bf47554`'s own hardcoded 15000ms
507
+ floor existed because its formula (`health.timeout_ms` +
508
+ 2x`auditRetryTimeoutMs`, ~7500ms at the shipped 5000ms default)
509
+ UNDERSTATED the measured ~10.8-13.75s real-world worst case. Re-
510
+ deriving from `realLedgerClient`'s actual two round-trip shapes
511
+ (`src/cli/policy/intercept.ts`) explains the gap and closes it
512
+ properly: `query()` can cost up to **2x** `health.timeout_ms` (a
513
+ slow-but-alive server can independently near-max BOTH the
514
+ `initialize` handshake and the following `ledger_summary` call
515
+ before the second one finally times out — not just 1x), and the
516
+ deny-degraded audit retry can cost up to **3x**
517
+ `auditRetryTimeoutMs` (the fresh retry session's own `initialize`,
518
+ the first `ledger_add` attempt, and — only when that attempt is
519
+ rejected outright by an older/incompatible grounding-mcp rather
520
+ than timing out — the legacy `type: "fact"` fallback `ledger_add`).
521
+ The corrected formula, `requiredHookBudgetMs(T) = 2T +
522
+ 3*auditRetryTimeoutMs(T)` (new export, `src/cli/policy/
523
+ intercept.ts`), evaluates to 13750ms at the shipped 5000ms default —
524
+ within `7bf47554`'s own cited ~10.8-13.75s measurement range, unlike
525
+ the ~7500ms the old formula gave. Both round-trip shapes are
526
+ measured independently against the REAL `realLedgerClient` /
527
+ `recordPolicyDecisionOnSession` (not just asserted) in
528
+ `tests/e2e/policy-intercept.test.ts`'s new "hook-budget-ledger-margin
529
+ derivation" suite, via a fake grounding-mcp subprocess that can
530
+ independently delay `initialize`, `ledger_summary`, and
531
+ `ledger_add`, and can force the legacy fact-type fallback.
532
+
533
+ 2. **Durable, generic validate/doctor guard.** New
534
+ `checkHookBudgetLedgerMargin` (`src/cli/validate/checks.ts`, wired
535
+ into both `harness validate` and `harness doctor`) checks the
536
+ corrected formula against an ARBITRARY manifest's OWN
537
+ `tools.mcp[grounding-mcp].health.timeout_ms` — not the shipped
538
+ 5000ms default — and against every ENABLED `policy_packs[]` entry's
539
+ resolved hooks via the shared `resolveBuiltin` registry lookup, for
540
+ every runtime `harness apply` can target, instead of
541
+ `7bf47554`'s test hand-importing three specific pack modules. A pack
542
+ whose blocker gates on a filesystem verdict marker rather than a
543
+ live ledger round-trip (`solution-acceptance` /
544
+ `solution-acceptance-writeguard`) is correctly excluded — flagging
545
+ it would be a false positive. An under-budgeted hook is reported as
546
+ an `error` naming both the hook's own `budget_ms` and the ledger's
547
+ `health.timeout_ms` (plus the derived requirement), so e.g. an
548
+ operator who raises `health.timeout_ms` without raising a
549
+ pack-shipped hook's fixed `budget_ms` in lockstep is now caught at
550
+ lint time instead of silently reopening the gap. Also fixes the
551
+ matcher this check (and, incidentally,
552
+ `tests/runtime/hook-budget-ledger-margin.test.ts`'s own
553
+ `blockingInterceptHooks`) uses to recognise a `harness policy
554
+ intercept` hook: the new `isPolicyInterceptCommand`
555
+ (`src/cli/policy/intercept.ts`) matches the subcommand tokens
556
+ regardless of a leading interpreter/env-var prefix (`node
557
+ /path/to/dist/cli/index.js policy intercept`, `npx harness policy
558
+ intercept`, `FOO=bar harness policy intercept`) or a trailing flag
559
+ (`harness policy intercept --hook <name>`), instead of the verbatim
560
+ `command.trim() === "harness policy intercept"` compare every other
561
+ call site in this codebase still (deliberately, out of this task's
562
+ scope) uses — an operator-authored `manifest.hooks[]` entry is under
563
+ no obligation to spell the command exactly the way harness's own
564
+ generators do, and under-recognition here is a false NEGATIVE on a
565
+ security check. Fix round 1 (review 2026-08-09) additionally widens
566
+ `isPolicyInterceptCommand`'s TRAILING boundary to also accept a
567
+ semicolon or quote character glued directly onto the subcommand word
568
+ with no whitespace (`harness policy intercept; echo done`, `sh -c
569
+ "harness policy intercept"`), which the whitespace-or-end-only
570
+ boundary shipped in this task's first commit missed; a trailing `)`,
571
+ `|`, `&`, or backtick immediately abutting the word remains a known,
572
+ deliberately-unclosed gap (documented in the function's own doc
573
+ comment) rather than a runtime bypass — a missed match here only
574
+ means the hook is not classified as ledger-consulting for THIS
575
+ check, never a change to what the hook actually does at runtime.
576
+
577
+ **Fix round 1 (review 2026-08-09): two corrections to this task's own
578
+ first commit, made in the name of not overclaiming what the guard
579
+ above actually guarantees.**
580
+
581
+ - **The `2T + 3R` formula's "worst case" claim is scoped to the
582
+ pure-timeout hang shape, not every degradation shape.** The formula
583
+ (and the diagnostic message above) previously read as an unqualified
584
+ worst-case bound. It is not one: `query()`'s `2T` half assumes the
585
+ pooled `LedgerSession`'s session-wide `timedOut` latch trips (making
586
+ the FIRST `record()` attempt instant, per `realLedgerClient`'s own
587
+ doc comment) — true only when `query()` degrades via an actual
588
+ timeout. A `query()` that instead degrades via a NON-timeout path
589
+ (`ledger_summary` returning a genuine JSON-RPC error, an unparseable
590
+ payload, or a payload missing the `entries` shape —
591
+ `LedgerSession.querySummary`'s non-timeout `degraded` branches,
592
+ `src/policies/ledger-client.ts`) never sets that latch, so the first
593
+ `record()` attempt afterwards is a REAL round-trip too — up to
594
+ another `2T` (the `type: "policy_decision"` attempt plus a possible
595
+ legacy `type: "fact"` fallback) BEFORE the `3R` retry sequence even
596
+ starts. Worst case on this "error-then-hang" shape is therefore up to
597
+ `4T + 3R` — 23750ms at the shipped `T=5000` default, against this
598
+ guard's `13750ms` floor. The formula is DELIBERATELY NOT raised to
599
+ `4T + 3R`: doing so would fail `checkHookBudgetLedgerMargin` against
600
+ the shipped, reviewer-confirmed-clean fresh-init budget of `15000ms`
601
+ (task `7bf47554`/#414, out of this task's scope) at the same
602
+ `T=5000` default, since `15000 < 23750`. Instead, `requiredHookBudgetMs`'s
603
+ doc comment (`src/cli/policy/intercept.ts`) and the
604
+ `checkHookBudgetLedgerMargin` diagnostic message now both name this
605
+ residual explicitly as a KNOWN, un-closed gap rather than implying a
606
+ guarantee neither actually gives — the guard backs delivery of the
607
+ fail-closed verdict on the pure-timeout hang shape only. This
608
+ residual is NOT confined to one runtime projection: Claude Code and
609
+ Codex both convert `budget_ms` into the outer hook-kill seconds via
610
+ the identical `hookTimeoutSeconds` helper
611
+ (`src/cli/apply/generate-settings.ts`, shared by
612
+ `generate-codex-config.ts`) since task `7bf47554`/#414, so a manifest
613
+ at the shipped defaults is equally exposed on both — checked against
614
+ both generators' current source for this note, not assumed.
615
+
616
+ - **The diagnostic message no longer attributes a `3× deny-degraded
617
+ audit-retry budget` to hooks that never run one.** The four
618
+ ledger-consulting pack blockers (`branch-protection`,
619
+ `understanding-before-execution`'s `pre-tool-use` /
620
+ `codex-pre-tool-use`, `post-merge-gate`) only ever call
621
+ `queryLedgerByTag` (open session, one `querySummary`, dispose) — they
622
+ never call `ledger_add`, so they have no deny-degraded audit-retry
623
+ step of their own; their real worst case is bounded at up to
624
+ `2×timeout_ms` for the query alone, not `2T + 3R`. The shared
625
+ `${required}ms` floor is kept identical across direct
626
+ `harness policy intercept` hooks and these pack blockers on purpose
627
+ (a deliberately conservative carryover, not a separately-derived and
628
+ separately-tested per-kind bound), but the message text explaining
629
+ WHY now differs per hook kind instead of asserting a retry sequence
630
+ that does not exist for the pack blockers. `post-merge-gate`
631
+ specifically already fails OPEN (allow) on a degraded or unreachable
632
+ ledger by its own design (`src/cli/pack/hook-post-merge-gate.ts`'s
633
+ "Fail posture: OPEN" note, and its own `budget_ms: 15000` comment in
634
+ `src/policy-packs/builtin/post-merge-gate.ts` already said as much)
635
+ — a hook killed by the outer timeout there reaches the same allow
636
+ outcome its own degraded-handling would already choose, so this
637
+ check is kept for it only to stop a merely-slow-but-alive ledger from
638
+ needlessly stalling the gate, not because a fail-closed verdict is at
639
+ risk. `src/cli/validate/checks.ts` now tags each collected hook with
640
+ its ledger-traffic shape (`isPolicyInterceptHook`,
641
+ `isFailOpenOnDegraded`) to pick the right explanatory text; the
642
+ numeric threshold, hook selection, and severity are all unchanged.
643
+
644
+ `tests/runtime/hook-budget-ledger-margin.test.ts`'s original
645
+ hand-imported per-surface assertions are updated to source their margin
646
+ bound from the corrected formula (previously understated) and gain a
647
+ new describe block running the generic `checkHookBudgetLedgerMargin`
648
+ guard against the very same shipped manifests, closing the loop the
649
+ file's own comment named as future work. New/updated tests:
650
+ `tests/cli/validate.test.ts` (fixture pairs for AC1/AC2 plus the
651
+ matcher-fix, pack-genericity, and negative-control cases, plus fix
652
+ round 1's semicolon-chained and quote-wrapped matcher cases),
653
+ `tests/cli/doctor.test.ts` (doctor-surface parity + `format()`
654
+ rendering), `tests/runtime/hook-budget-ledger-margin.test.ts`,
655
+ `tests/e2e/policy-intercept.test.ts`.
656
+
657
+ - **SECURITY: the operator-approval escape matcher (`isEscapeCommand`,
658
+ `src/cli/pack/approve-escape.ts`) used JS's generic `\s` whitespace
659
+ class where bash's actual lexical blank set was meant, letting a
660
+ non-bash-blank codepoint move a report heredoc's real delimiter word
661
+ (as bash reads it) away from the word the matcher extracts** (task
662
+ `623640a5`). Ground-truthed against GNU bash with a PATH-stub harness:
663
+ of the 25 codepoints JS's `\s` class matches, only TAB and SPACE are
664
+ bash lexical blanks; the other 23 (NBSP U+00A0, LF, CR, VT, FF, and 18
665
+ further Unicode space-separator/line-separator/BOM codepoints) glue
666
+ onto the adjacent token instead of being stripped as an insignificant
667
+ separator. Measured exploit shape (the 2026-08-04 halt that opened this
668
+ task): a non-breaking space right after a report heredoc's `<<'X'`
669
+ intro closing quote was accepted by the matcher's `\s*$` as
670
+ insignificant trailing whitespace, so the matcher extracted delimiter
671
+ `X`; bash instead glues the NBSP onto the quoted word, so its real
672
+ delimiter is `X`+NBSP, and the body line `X`+NBSP (matching the
673
+ matcher's delimiter, not bash's) closes the heredoc one line early:
674
+ the following body line then runs as a real top-level command
675
+ (`git push origin master` in the measured repro) instead of staying
676
+ inert report text. Fix: every regex in the module that means "a
677
+ separator bash will actually treat as blank here" (the heredoc-intro
678
+ trailing/leading whitespace, the `harness`/`approve` separator, the
679
+ heredoc command-part whitelist, the command-part right-trim, and the
680
+ post-terminator blank-line check) now uses `[ \t]`, never `\s`.
681
+ Behavior change operators should know about: a `harness approve`
682
+ heredoc carrying a non-bash-blank codepoint in one of those positions
683
+ used to be accepted as a clean escape and routed to the normal
684
+ interactive ASK permission prompt; it is now DENIED outright (fails
685
+ closed to the ordinary understanding-gate block), since ASK implied a
686
+ false equivalence to the safe heredoc shape. The PreToolUse
687
+ discoverability hint (`approveEscapeHint`,
688
+ `src/cli/pack/hook-pre-tool-use.ts`) now also names the offending
689
+ codepoint (e.g. "contains U+00A0 ... where an ordinary space or tab was
690
+ expected") when a blocked command carries one, so an agent that emitted
691
+ invisible whitespace gets a reason instead of silently retrying the
692
+ same bytes. New regression tests: `tests/cli/pack-approve-escape.test.ts`
693
+ (the full 25/23 codepoint enumeration measured against the live JS `\s`
694
+ class, the NBSP incident itself, and a mechanical guard, module source
695
+ with comments/strings stripped, asserting no future regex in the
696
+ module can reintroduce a bare `\s` token, mutation-verified to actually
697
+ redden); `tests/cli/pack-hook-pre-tool-use.test.ts` (the hint still
698
+ surfaces for the newly-blocked shapes, now naming the codepoint; a
699
+ negative control confirms an ordinary metacharacter block names no
700
+ codepoint).
701
+
702
+ - **SECURITY: `harness doctor` now detects template-policy drift, an
703
+ installed manifest that lacks a shipped `operator_only` kill-switch
704
+ security policy** (task `adf037c1`). Measured 2026-08-06: a 0.44.0
705
+ machine whose `~/.harness/harness.yaml` predated the kill-switch
706
+ defenses (`deny-kill-switch-bypass`, `deny-session-env-strip`,
707
+ `deny-pause-sentinel-forgery`) had the documented `harness pause`
708
+ bypass live as ALLOW, with nothing surfacing the gap: `harness apply`
709
+ never retroactively adds newly-shipped default policies to an
710
+ already-materialized manifest, so security policies introduced after
711
+ install reach only fresh installs. Doctor's new "Template drift
712
+ (shipped security policies)" section compares the installed manifest
713
+ against `shippedOperatorOnlyPolicyNames()` (the `operator_only` set
714
+ parsed from `FULL_TEMPLATE` itself, kept honest by the full-template
715
+ parity test), so it can never drift from what `harness init --template
716
+ full` writes and automatically extends when a new operator_only policy
717
+ is added. Each missing policy is an ERROR (doctor exits non-zero, the
718
+ standard CI/dogfood signal), naming the policy and the rehydration
719
+ path. A same-named-but-DOWNGRADED policy (present but no longer
720
+ `operator_only: true`, e.g. the historical `requires.ledger_tag` shape
721
+ a ledger write can satisfy, or `enforcement: warn`) is flagged as a
722
+ distinct error too: name-presence alone would pass a bypassable
723
+ kill-switch as no-drift, the exact aged-manifest class this check
724
+ targets. A stale/typo'd `doctor.ignore_template_drift` entry matching
725
+ no shipped name is surfaced as a warning so a policy rename cannot
726
+ silently strand an opt-out. Scope is the `operator_only` security floor regardless of profile
727
+ (a solo/team install missing kill-switch protection is flagged too;
728
+ that is the security core, per operator decision 2026-08-08), and it
729
+ does NOT nag about full-only convenience policies a smaller profile
730
+ never carried. Deliberate opt-out: `doctor.ignore_template_drift:
731
+ [<policy-name>, ...]` in the manifest, a NEW minimal `doctor:` schema
732
+ section that silences only the doctor report line and changes no
733
+ enforcement. This is deliberately NOT a `policies[].enabled: false`
734
+ flag: the runtime policy engine has no such concept, so an operator
735
+ setting it would believe a policy disabled while it still fired (a
736
+ footgun the CLI help + docs already implied but no code backed). The
737
+ original AC's two-severity split (operator_only error vs cosmetic
738
+ warning) was consciously narrowed to a single operator_only error tier
739
+ per the same operator scope decision, since cosmetic policies are no
740
+ longer compared. Shared `checkTemplatePolicyDrift` in
741
+ `src/cli/validate/checks.ts` (doctor-only surface today; validate is
742
+ about internal consistency, not external-reference comparison, so it is
743
+ intentionally not wired to fail `harness validate`/`apply`).
744
+ docs/okf/pause-vs-gate-kill-switch.md documents the drift check and the
745
+ opt-out; fixtures cover missing/present/opt-out/partial, and a parity
746
+ test pins the shipped set so a template rename reddens rather than
747
+ silently shrinking the check.
748
+
749
+ - **SECURITY: read-only Bash classifier no longer treats mutating
750
+ argument forms of allowlisted git subcommands as read-only** (task
751
+ `9d1fff1b`). `GIT_READ_ONLY_SUBS` (`src/runtime/read-only-bash.ts`)
752
+ was a bare-form allowlist of subcommand NAMES; measured against real
753
+ git 2.50.1, six argument forms of those names mutate while classifying
754
+ read-only: `git branch -D <b>` (deletes a branch), `git tag <name>`
755
+ (creates a tag), `git remote set-url` (rewrites config), `git fetch
756
+ <url> <refspec>` (writes an arbitrary local ref), `git diff
757
+ --output=<path>` (creates/overwrites a file; `log`/`show` accept the
758
+ same option), and `git reflog expire`/`delete` (destroys reflog
759
+ entries). All four consumers of the classifier — the risk-classifier
760
+ read-only severity floor, both PreToolUse understanding-gate hooks, and
761
+ the solution-acceptance write-guard — funnel through the same
762
+ `classifyTokens`, so all four are corrected by the single fix and none
763
+ needed a consumer-side change (measured: the full pack + risk-classifier
764
+ suites, 602 tests, unchanged). The `bin === "git"` branch now applies a
765
+ per-subcommand POSITIVE argument-form check (`isReadOnlyGitInvocation`):
766
+ branch/tag are read-only only with no non-flag operand (every mutation
767
+ needs the branch/tag name; branch also denies the glued no-operand
768
+ writes like `--set-upstream-to=`); remote only for the `show`/`get-url`
769
+ verbs or bare `-v`; fetch only for bare or a single remote/URL (a
770
+ second positional refspec blocks — the escalation to an arbitrary
771
+ local ref); reflog only for bare/`show`/flag forms (expire/delete/drop
772
+ are always the first arg); everything after a bare `--` is folded into
773
+ the positional view. Two write/exec vectors reach git's SHARED option
774
+ parser and transport layer and are therefore forfeited GLOBALLY, above
775
+ the per-subcommand switch, for every allowlisted git subcommand (review
776
+ round 1, measured against git 2.50.1): `--output=<path>` creates a file
777
+ at option-parse time — measured writing on `rev-list`/`shortlog`/`blame`
778
+ as well as diff/log/show — and `--upload-pack=`/`--exec=`/`--receive-pack=`
779
+ plus `ext::`/`fd::` positional transports run an operator-named binary
780
+ locally (measured `git fetch --upload-pack=<script> <repo>` executing
781
+ the script). The choice is positive-shape, not a write-flag
782
+ denylist, so a git argument this floor has not reasoned about
783
+ over-blocks (annoying) instead of laundering a write (unsafe) — the
784
+ same fail-closed stance as the existing `npm audit` guard, and
785
+ deliberately NOT the enumerate-every-exception shape whose repeated
786
+ failure halted the neighbouring task 5b5d1022. Literal verb/flag
787
+ comparisons use the repo's raw-or-decoded form (task fdee7d0f) so a
788
+ quoted spelling (`git remote se"t-url"`) fails the positive match and
789
+ blocks rather than slipping through. Explicitly out of scope (separate
790
+ tasks): global-option / path-qualified git (`git -C <d>`, 5b5d1022),
791
+ GNU long-option abbreviations (dd055c1d), and env-var exec equivalents.
792
+ negative tests pin the mutating forms (reproduced against git 2.50.1),
793
+ positive controls pin the bare forms plus the read corpus (`rev-parse
794
+ --git-dir`, `ls-files -c`, `log -c HEAD`, `show -c HEAD`, `diff --stat`,
795
+ `status --short`, and single-positional `fetch`/`ls-remote` from a URL);
796
+ a mutation probe confirms reverting to the bare-name allowlist reddens
797
+ the negative suite, and a per-arm probe confirms each raw-or-decoded
798
+ decode arm is load-bearing (deleting it reddens a quoted no-positional
799
+ write form). The over-blocked read forms (`git tag -l 'v*'`,
800
+ `git branch --contains HEAD`, separated fetch flag values, ...) are the
801
+ accepted conservative cost, documented in `docs/risk-gate.md`.
802
+
803
+ - **SECURITY: block/require_approval policies now fail CLOSED when their
804
+ evidence source is degraded, instead of silently mapping to the
805
+ never-blocking `warn-degraded`** (task `f1aea826`). Measured 2026-08-06 on
806
+ 0.44.0 (isolated probe home, `git push` event without its preflight tag):
807
+ a healthy ledger denied, every broken-ledger shape (ENOTDIR, corrupt file,
808
+ directory) denied, but `--ledger-timeout` at 1/25/50/100 ms flipped the
809
+ same event to **ALLOW**, because the timeout path mapped to
810
+ `warn-degraded`, which `isBlockingDecision` never treats as blocking.
811
+ `evaluateOnePolicy`'s degraded family (ledger timeout/spawn failure,
812
+ unresolved template variables, invalid `within`, thrown evaluator,
813
+ schema-invariant branch) now routes through a tier-aware
814
+ `degradedOutcome`: `warn` policies keep the non-blocking `warn-degraded`
815
+ (availability first, unchanged), `block`/`require_approval` policies
816
+ produce the NEW blocking `deny-degraded` outcome. The `deny-degraded`
817
+ envelope deliberately bypasses the policy's `ux:` surface and names the
818
+ degraded cause (sanitised: the transport reason embeds subprocess
819
+ output, so the envelope interpolation is bounded to 200 chars with
820
+ control characters stripped) plus an operator-facing recovery path, so
821
+ nobody debugs a phantom missing tag on an unreadable ledger. The
822
+ fail_open opt-out is deliberately ABSENT from that agent-facing text
823
+ (review finding: a deny that includes its own disable recipe is not a
824
+ gate, and nothing prevents the blocked agent from editing the
825
+ manifest); it is named on the operator surfaces instead: the verbose
826
+ stderr diagnostic, docs/risk-gate.md, and the OKF fail-posture matrix.
827
+ **ACCEPTED SEMANTIC CHANGE
828
+ an upgrading operator should know about:** a degraded ledger (including
829
+ "grounding-mcp not declared in manifest" under block-tier policies) now
830
+ DENIES where it silently allowed; the explicit manifest opt-out
831
+ `risk.degraded_fail_posture: fail_open` (schema default
832
+ `preserve_enforcement`, `src/schema/risk.ts`) restores the pre-0.45
833
+ availability-first mapping for every tier, and the operator-only
834
+ `harness pause` kill switch remains the deadlock escape (it is honoured
835
+ BEFORE manifest load); the shipped init template documents the knob as
836
+ a COMMENTED-OUT line on purpose (the schema is strict, so an emitted
837
+ key would make the manifest unreadable by a pre-0.45 binary, and a
838
+ manifest load failure is allow at the hook layer: on a mixed-version
839
+ fleet that would turn a downgrade into a silent full fail-open). Audit
840
+ completeness rides along, best-effort and precisely bounded: the pooled
841
+ ledger session's timeout latch previously guaranteed that exactly the
842
+ degraded deny lost its own audit row (every post-timeout call on that
843
+ session short-circuits), so `realLedgerClient.record` now retries once
844
+ per invocation over a fresh session, RESERVED for `deny-degraded` rows
845
+ (an allow/warn row failing first cannot consume it) and capped at
846
+ max(250ms, timeoutMs/4) per call — two calls, so the added stall is at
847
+ most half a timeout once the ledger timeout is >=1s, and bounded at
848
+ 500ms absolute below that, where the floor dominates (the formula is
849
+ pinned as the exported `auditRetryTimeoutMs`); a longer or unreserved
850
+ retry would stall the deny path toward the outer hook budget, where a
851
+ hook timeout is conventionally allow. With grounding-mcp absent from the manifest
852
+ altogether there is no transport at all: a BLOCKING decision on that
853
+ path now emits a dedicated "has NO audit row" stderr line instead of
854
+ the previous silent no-op. `isBlockingDecision` is now exported and
855
+ shared with the CLI wrapper's pending-approval staging, which had a
856
+ drifted hand-rolled copy. New outcome threaded through every consumer:
857
+ `harness audit` `VALID_OUTCOMES`, `explain`/`audit` `--outcome` filters,
858
+ the verbose stderr diagnostic (which names the outcome, the fail-closed
859
+ posture and the operator opt-out, cause-neutrally — the true cause
860
+ follows on its own reason line), a NEW
861
+ default-verbosity one-line stderr operator hint per degraded-denied
862
+ event (carrying the decision's OWN sanitised reason, since
863
+ deny-degraded has five causes and only one of them is the ledger — a
864
+ hardcoded ledger-fault wording sent operators of the other four to a
865
+ green `harness doctor`; pointing at doctor and the docs, deliberately
866
+ without the opt-out literal on the default channel, and suppressed
867
+ under verbose where the full diagnostic supersedes it), and the
868
+ `checkPolicyGroundingMcp` validate/apply warning, whose pre-0.45
869
+ "every policy will fire in degraded warn-mode" wording described the
870
+ inverse of the new block-tier behaviour and is now tier-aware and
871
+ test-pinned; `smoke` assertions classify the new outcome as deny via
872
+ the unchanged envelope path. A stale-contract drift guard now runs in
873
+ the suite: `tests/no-stale-fail-posture-claims.test.ts` fails if the
874
+ pre-0.45 phrasings OR the round-4-enumerated restatement shapes
875
+ ("ledger degradation → warn-degraded" untiered, "only ever blocks if
876
+ grounding-mcp", the untiered fail-open framing) reappear under `src/`
877
+ or `docs/` outside a per-phrase allowlist with freshness checks and
878
+ one positive control per needle. Honest bound, stated in the guard
879
+ itself: it is a literal/pattern grep pinning the mechanically
880
+ greppable phrasings the review rounds found, not a semantic checker —
881
+ wrong-modality claims were fixed by hand and are not pinned, and a
882
+ future paraphrase avoiding all needles still needs eyes (four review rounds
883
+ each found a fresh such surface; docs quoting message literals
884
+ verbatim was the recurring root cause, so the updated docs now
885
+ describe messages instead of quoting them); the same sweep updated
886
+ docs/writing-custom-policies.md,
887
+ docs/CLI.md, docs/examples/pandora-bootstrap.md, the OKF
888
+ producer-wiring bundle, the ledger-client module header, the init
889
+ composer's wizard advisory, and marked the superseded ROADMAP Phase 4
890
+ acceptance bullets. NOT closed here, named so no
891
+ doc overclaims: the OUTER hook-budget layer (a hook exceeding its
892
+ `budget_ms` is allow by harness contract) stays fail-open and is not
893
+ reachable from this repo's schema; keep hook budgets comfortably above
894
+ the ledger timeout (the shipped template's 1000-2000ms budgets vs the
895
+ 5000ms grounding-mcp health timeout mean the HANG shape can still blow
896
+ the hook budget before the deny lands; a validate/doctor consistency
897
+ check is filed as a follow-up task), and malformed event JSON or a
898
+ failed manifest load remain allow-with-stderr at the CLI wrapper.
899
+ docs/risk-gate.md "Degraded mode",
900
+ docs/okf/gate-fail-posture-matrix.md,
901
+ docs/okf/policy-engine-producer-wiring.md and
902
+ docs/okf/debug-verb-selection.md rewritten to the new contract;
903
+ the two pre-0.45 pins asserting warn-degraded-never-blocks for block
904
+ tier were rewritten deliberately alongside (unit + e2e), plus new
905
+ fixtures: require_approval+timeout blocks, warn+timeout allows,
906
+ fail_open opt-out restores the old mapping end-to-end through the real
907
+ manifest parse, genuine-timeout e2e (hanging fake grounding-mcp), an
908
+ AC4 e2e pinning exactly TWO subprocess spawns with the `deny-degraded`
909
+ row landed in the ledger despite the latch, retry allocation (a warn
910
+ row failing first does not consume the deny row's retry),
911
+ envelope-over-ux precedence, envelope sanitisation, the opt-out's
912
+ absence from the envelope and presence on stderr, the no-transport
913
+ "has NO audit row" line, and `--outcome deny-degraded` accepted by
914
+ audit/explain filters.
915
+
916
+ - nanoid bumped 3.3.16 → 3.3.18 via `npm audit fix` (lockfile-only, no source
917
+ change), closing GHSA-2v37-7h3g-55p8 / CVE-2026-67213 (High, CVSS 8.2:
918
+ `customAlphabet`/`customRandom` loop indefinitely when `size` is 0; affected
919
+ range < 3.3.17). Dev-only transitive (vitest → vite → postcss → nanoid);
920
+ postcss's declared `^3.3.16` already admits the fix, so no `package.json`
921
+ change and no override introduced (the same shape as the 0.42.0 entry that
922
+ carried nanoid 3.3.12 → 3.3.16 for the postcss advisory). harness source
923
+ never calls nanoid, so the advisory's mechanism is not reachable from the
924
+ shipped CLI; the practical effect is `npm audit` back to 0 findings and this
925
+ repo's own preflight npm-audit check unblocked. Note the structural residue
926
+ the 0.42.0 entry already accepted: postcss's range floor (3.3.16) is below
927
+ the patched 3.3.17, so only the lockfile pin holds the fix (conscious
928
+ no-override decision, unchanged). (task `b81e8e09`)
929
+
930
+ - **SECURITY: the Risk Gate's environment resolver ignored a `git
931
+ switch`/`checkout <branch>` at the head of a Bash command, so a
932
+ same-command branch hop onto a production branch ran under the
933
+ PRE-switch (often `unknown`) label** (task `341e024b`). Measured
934
+ 2026-08-06 (isolated probe home, harness 0.44.0, cwd on a non-production
935
+ branch): `git switch main && rm -rf <path>` and the `git checkout main`
936
+ equivalent both classified `critical` severity / `unknown` environment /
937
+ **ALLOW**, while the comparison `cd <repo-on-main> && rm -rf <path>` form
938
+ already denied — the branch resolver only ever read `.git/HEAD` at the
939
+ hook's cwd (or, since the `cd`-target fix below, a leading `cd <path>`'s
940
+ target), never a `git switch`/`checkout` argument. Alltagsrelevant: the
941
+ post-merge convention of switching back to the default branch before
942
+ continuing work is exactly this shape (`git switch main && rm -rf
943
+ node_modules && npm ci`), so it ran ungated while the identical work with
944
+ a leading `cd` did not.
945
+
946
+ `src/runtime/bash-prefix-parse.ts`'s `parseBashPrefix` now also extracts
947
+ a `branchTarget` from a leading `git [-C <path>] (switch|checkout)
948
+ <branch> [&&|;]`, mirroring the existing `cdTarget` extraction (a new
949
+ `consumeLeadingGitSwitch`, sharing the same command-head-only, two-pass,
950
+ no-shell-simulation discipline the module's `cdTarget`/`inlineEnv`
951
+ parsing already has — task `1a8a103d`). Deliberately conservative,
952
+ unlike `cdTarget`'s bidirectional risk (which can raise OR lower the
953
+ resolved environment depending on the target, a pre-existing, documented
954
+ asymmetry — G5, task `98ad072f`/2026-07-27 — untouched by this task):
955
+ `git checkout -- <path>` (a file restore, not a branch change) and an
956
+ unresolvable `$VAR`/`${VAR}` branch argument set NO branch signal — never
957
+ guessed. `src/cli/policy/intercept.ts`'s new `applyBranchSwitchUpgrade`
958
+ merges the parsed branch candidate against the existing `resolverGit`
959
+ (cwd- or cd-target-based) UPGRADE-ONLY: both are run through the same
960
+ `resolveEnvironment` call with identical env/kube inputs, and the
961
+ switch-target branch is only adopted when it resolves to a MORE
962
+ dangerous environment than the base; a switch AWAY from a production
963
+ branch never downgrades an already-production classification.
964
+
965
+ 5 new end-to-end fixtures in `tests/runtime/intercept-cli-bash-prefix.test.ts`
966
+ pin the acceptance criteria (`git switch main`/`git checkout main` from
967
+ a non-prod cwd now deny; `git switch <non-prod-branch>` from a repo
968
+ already on `main` still denies — no downgrade; `git checkout --
969
+ <path>` and `git switch $BRANCH` set no signal and stay allow), plus
970
+ unit coverage for the parser itself in
971
+ `tests/runtime/bash-prefix-parse.test.ts` (slashed branch names, the
972
+ optional `-C <path>` skip, the flag/variable/missing-separator
973
+ exclusions, and — falling out of the existing two-pass loop for free —
974
+ a leading `cd <path> && git switch <branch>` chain resolving both
975
+ candidates). Existing `cdTarget`/inline-env fixtures are unchanged and
976
+ green. `docs/risk-gate.md`'s "Environment resolvers" section documents
977
+ the new upgrade-only branch signal alongside the pre-existing
978
+ bidirectional `cd` one.
979
+
980
+ Fix round 1 (reviewer-flagged, `accept_with_notes`): the upgrade-only
981
+ merge itself was verified sound (no downgrade path), but two gaps
982
+ remained. **MEDIUM (security):** a QUOTED branch name bypassed the
983
+ gate entirely — `consumeLeadingGitSwitch` read the branch token
984
+ INCLUDING the quote characters, so `git switch "main" && rm -rf <path>`
985
+ extracted `branchTarget` as the literal 6-character string `"main"`,
986
+ which never matches a `branch_patterns` entry like `main`, so no
987
+ upgrade fired and the destructive command ran ungated — identical in
988
+ effect to the pre-fix unquoted case above. The sibling `cd`-target
989
+ parser already stripped quotes (`skipPathToken`/`consumeLeadingCd`);
990
+ this was an inconsistency, and the module doc's given reason for
991
+ skipping a quoted branch form ("real git branch names cannot contain
992
+ whitespace, so this covers slashed names without needing a quoted
993
+ form") was itself wrong — a whitespace-free branch name can still be
994
+ quoted by the operator. `consumeLeadingGitSwitch` now reads a single-
995
+ or double-quoted branch token the same way `skipPathToken` reads a
996
+ quoted path: literal content, quotes stripped. A `$`-containing
997
+ DOUBLE-quoted branch argument is still left unresolved (real bash
998
+ interpolates inside double quotes, consistent with the existing
999
+ unquoted-`$VAR` rule); a SINGLE-quoted one is always literal, since
1000
+ single quotes never interpolate in real bash. New e2e fixtures in
1001
+ `tests/runtime/intercept-cli-bash-prefix.test.ts` pin `git switch
1002
+ "main"` / `git switch 'main'` / `git checkout "main"` from a non-prod
1003
+ cwd now denying, a quoted switch away from `main` still not
1004
+ downgrading, and a double-quoted `"$BRANCH"` still not guessed; new
1005
+ parser-unit coverage in `tests/runtime/bash-prefix-parse.test.ts`
1006
+ covers the quote-stripping itself, the double-quote-`$` exclusion, the
1007
+ single-quote-`$` literal case, unterminated/empty quoted literals, and
1008
+ the missing-separator rule for a quoted branch. Mutation-probed:
1009
+ disabling the quote-stripping branch turns exactly the 12 new e2e/unit
1010
+ assertions red.
1011
+
1012
+ **LOW (maintainability):** `ENV_RANK` in `src/cli/policy/intercept.ts`
1013
+ is a hand-maintained copy of `ENV_PRECEDENCE` in
1014
+ `src/runtime/environment-resolver.ts`; the `Record<MatchableEnvironment,
1015
+ number>` type catches an added/removed environment but not a REORDER,
1016
+ which could silently invert the upgrade-only direction. A new drift
1017
+ guard, `tests/runtime/intercept-env-rank-drift-guard.test.ts`, measures
1018
+ `ENV_PRECEDENCE`'s real order live via `resolveEnvironment` (four
1019
+ resolvers, one per environment, each on a different signal kind so all
1020
+ four can fire on one probe envelope at once; the conflict winner reveals
1021
+ the true order by elimination) and, for every adjacent pair in that
1022
+ measured order, round-trips `runInterceptCli` to confirm
1023
+ `applyBranchSwitchUpgrade`'s `ENV_RANK`-driven behavior agrees.
1024
+ Mutation-probed: swapping two adjacent entries in `ENV_PRECEDENCE`
1025
+ (without touching `ENV_RANK`) turns exactly the affected pair's two
1026
+ assertions red.
1027
+
1028
+ **LOW, documentation only (not built):** a chained double switch (`git
1029
+ switch dev && git switch main && rm ...`) resolves `branchTarget` as
1030
+ `dev` (first-switch-wins), missing `main`. Multi-switch parsing was
1031
+ deliberately NOT added — it would widen the parser's surface toward the
1032
+ false-positive class task `dbc6d303` already measured. The module doc
1033
+ on `consumeLeadingGitSwitch` and `docs/risk-gate.md` now state this
1034
+ limit explicitly so it is not over-trusted.
1035
+
1036
+ ### Changed
1037
+
1038
+ - Audited `src/runtime/recovery-git-commit.ts`'s three JS `\s`-based
1039
+ tokenizers (the `git\s+commit` anchor and `tokenize`'s leading-skip and
1040
+ token-continuation checks) against the same JS-`\s`-vs-bash-blank
1041
+ divergence class `623640a5` closed elsewhere — reviewer residual from
1042
+ that task. Measured against real bash and git (PATH-stub): unlike
1043
+ `623640a5`, this module's over-split is safe by construction, not a
1044
+ fix. No production change; the safe direction is pinned in
1045
+ `tests/runtime/recovery-git-commit.test.ts` with live bash+git evidence
1046
+ for every call site plus a hermetic pin across all 23 non-bash-blank
1047
+ `\s` codepoints. (task `822a508d`)
1048
+
1049
+ ### Fixed
1050
+
1051
+ - **`init`'s MCP registration could mutate/read the WRONG `claude` CLI registry file under a non-default home, and treated an "already registered" outcome as a hard failure that blocked the settings.json migration** (task `fb3e4dce`, two reviewer findings on run `2026-07-18-init-mcp-wiring-claude-code`). Finding 2: `realClaudeMcpExec` (`src/io/claude-mcp.ts`) spawned the real `claude` CLI with no explicit `env`, so it inherited `process.env` verbatim — identical to `ensureMcpServers`'s own drift-read directory only when that directory happened to be the OS default (`~/.claude.json`). A non-default `homeDir`/`registryPath` override (e.g. `harness init --home`) made the live CLI spawn mutate/read a DIFFERENT registry file than the one `ensureMcpServers` had just compared `desired` against. Fixed by threading a new `configDir` through every verb wrapper to `exec`'s new third argument; `realClaudeMcpExec` now sets `CLAUDE_CONFIG_DIR` in the spawned child's env from it. **Correction (review round 2, task `fb3e4dce`): the original fix set `configDir = path.dirname(registryPath)` unconditionally, which on the DEFAULT resolution path is `~` itself (not the CLI's own actual default `CLAUDE_CONFIG_DIR` of `~/.claude`), so it silently redirected every OTHER config-dir-relative path the CLI resolves (e.g. registry backups landing in `<HOME>/backups` instead of `<HOME>/.claude/backups`) — `resolveMcpConfigDirOverride` (`src/io/claude-mcp.ts`) now emits `configDir` only when the resolution actually used a `CLAUDE_CONFIG_DIR`/`homeDir`/`registryPath` override, leaving the pure default path's real spawn with unmodified `process.env` inheritance; the same helper now also backs `uninstall`'s `claude mcp remove` call (`src/cli/uninstall/index.ts`), which previously threaded no `configDir` at all under a non-default `--home`.** A `claude mcp get` verification that merely timed out or hit a missing CLI (inconclusive, not a positive "absent") no longer overrides a File-verified already-exists match on its own (`addAndVerifyAlreadyExists`); the wizard's post-registration message now distinguishes freshly `add-json`-registered servers ("registered N ...") from verified-already-exists ones ("confirmed N ... already registered"), instead of reporting both as "registered". Finding 3: `ensureMcpServers` reported an `add-json` "already exists" outcome exactly like any other failure, even when the desired server genuinely IS registered (e.g. a registration this run's own registry-file read raced ahead of/missed) — the wizard's `allOk` gate then blocked the dead-`settings.json`-`mcpServers`-block migration even though the target state already held. `addAndVerifyAlreadyExists` now confirms an "already exists" outcome via `claude mcp get` + a fresh re-read of the same registry file (empirically verified: `claude mcp get`'s stdout is prose and does not expose the registered command/args, so the spec comparison uses the file, not `get`'s own output) — a verified, spec-matching registration is surfaced as `EnsureServerResult.verifiedAlreadyExists.matches: true`, which `wireClaudeMcp`'s `allOk` check now treats as success; an unverified or spec-mismatched one preserves the prior conservative behavior unchanged. New coverage in `tests/io/claude-mcp.test.ts` (configDir threaded to every verb across an add/replace/get sequence for a non-default home; matching-spec, mismatched-spec, and get-can't-find-it already-exists variants; a plain "added" outcome never calls `get`) and `tests/cli/init-interactive.test.ts` (end-to-end: a verified-matching already-exists still runs the settings.json migration and prints no failure warning; a verified-mismatched one skips migration and prints the same warning as before this fix). Review round 2 adds: a real `claude mcp add-json` spawn (a throwaway shim binary, never the operator's real CLI/registry) pinning that an explicit `configDir` wins over the parent process's own `CLAUDE_CONFIG_DIR` and that omitting it inherits `process.env` verbatim; pure-function coverage of `resolveMcpConfigDirOverride`'s default-vs-override decision; a `claude mcp remove` configDir-alignment pin for `uninstall` under both an explicit `--home` and the pure default; a timed-out-`get`-still-checks-the-file case; and verb-order plus message-wording pins on the two existing already-exists wizard tests.
1052
+ - **The session-start preflight not-ready fail-log's default directory (`~/.harness/logs`) is now repo-wide test-isolation-safe instead of suite-local** (task `80f49922`, reviewer finding on `a48b9729`). `defaultFailLogDir()` called `os.homedir()` directly, so the throw-on-real-home-dir guard in `resolvePaths()` (`loader.ts:45-64`, already pinned by `tests/integration/operator-state-isolation.test.ts`) never covered this seam — only a suite-local `vi.mock("node:os")` pin in `preflight.test.ts` did, protecting the one caller that suite happened to know about, not any future test or child process. `defaultFailLogDir` now resolves via `resolvePaths()` (same idiom as `defaultReportsDir`'s caller in `approve/understanding.ts`), so a caller that forgets to inject `homeDir`/`configPath` now fails at the guard instead of silently writing into the operator's real home. Side effect fix: the fail-log dir now also respects `$HARNESS_HOME` and the legacy `~/.claude/` fallback instead of hardcoding `~/.harness/` regardless of the rest of the harness state's actual location (a pre-existing divergence from task `31fbf856`) — EXCEPT when `configPath` is injected without `homeDir`: `configPath` overrides the manifest path directly, so the log dir becomes `dirname(configPath)/logs` and does not route through home-dir resolution (or the `$HARNESS_HOME`/legacy tiers) at all in that case. The not-ready branch now catches only the guard's throw (`persistFailLog`'s own write failure already never throws, so it stays outside that try/catch) and folds it into the same `{ ok: false, reason }` shape a fail-log write failure already produces, so the SessionStart producer still never throws. New coverage in `tests/integration/operator-state-isolation.test.ts` (no `HARNESS_INTEGRATION_TESTS` opt-in, no subprocess spawn), all exercising `runSessionStartPreflight` directly: a no-injection negative control (asserts the guard blocks the write and the real `~/.harness/logs/` is byte-for-byte unchanged); a hermetic `homeDir`-injection case and a hermetic `configPath`-injection case (neither mocks `os` nor needs `HARNESS_ALLOW_REAL_GENERATED_DIR`, since an injected option bypasses the guard outright); a `$HARNESS_HOME` case (needs the `ALLOW_REAL` escape hatch to reach that precedence tier since neither `homeDir` nor `configPath` is injected, secured with the same real-`~/.harness/logs/`-unchanged assertion as the negative control); and a legacy `~/.claude/` fallback case, the only one of the five that mocks `node:os` — scoped to itself via `vi.doMock` + a dynamic re-import — because that precedence tier reads `os.homedir()` directly and has no LoaderOptions seam to override it. `preflight.test.ts`'s suite-local negative-control test is updated to assert the guard blocks the write; its `vi.mock("node:os")` pin stays in place as a second line of defense for every OTHER `os.homedir()` consumer in that suite (it no longer protects this one seam, which now fails closed independently of it).
1053
+ - **Two not-ready preflight fail-logs written in the same millisecond practically never overwrite each other anymore** (task `a48b9729`). The fail-log filename's ISO timestamp has millisecond resolution, so a second persist within the same ms silently clobbered the first; filenames now carry a 4-hex random suffix (`preflight-<repo>-<timestamp>-<hex4>.json`), leaving a 1-in-65536 same-millisecond residual collision chance rather than a guarantee. Alongside (test-only): the preflight suite pins `os.homedir()` to a throwaway tmp home so a not-ready test that forgets to inject `logDir` can never write into the operator's real `~/.harness/logs` (with a negative-control test), plus rotation boundary tests (exactly 20 → no eviction; 21 → exactly the oldest evicted), hostile-repo-basename containment tests, and a multi-failing-checks cap/join pin.
1054
+ - **`harness doctor`'s dead-`settings.json`-`mcpServers`-block check now honors `CLAUDE_CONFIG_DIR`** (task `f13c6fbd`, #423). The check read a fixed `<home>/.claude/settings.json`, so with a config-dir override it inspected a file Claude Code does not read. Resolution now mirrors `resolveClaudeUserRegistryPath`'s precedence (non-empty `CLAUDE_CONFIG_DIR` wins), the warning names the resolved path, and doctor gained an `envOverride` test knob so the dead-block tests stay hermetic against the operator's real environment. (This entry was itself backfilled by the first run of the new changelog-coverage gate below.)
1055
+ - **`harness dry-run`'s `bash_match` prediction under-predicted the quoted-shell-boundary family (`VAR='a; b' git push origin master`) that `harness policy intercept` already blocks** (task `f561e44c`, follow-up to `cf3dff51`). Task `cf3dff51` wired `normalizeCommandQuoteAware` into `policyMatchesEvent` (`src/runtime/intercept.ts`) as a fourth matching arm but, scoped narrowly to that one wiring point, deliberately left `dry-run`'s own independently-copied matcher (`src/cli/dry-run.ts`) untouched — reintroducing the debug-verb/runtime parity gap that file's own comment (and `docs/okf/debug-verb-selection.md`) has warned about since task `ea8becf5`. `policyMatchesTool` now tries the same fourth, quote-aware OR-arm, literally duplicated to mirror the amp-aware arm's own precedent (no shared-helper seam exists for this OR-chain yet). Debug-verb-only fix, no gate touched; `dry-run` was under-predicting, the fail-safe direction. New parity fixture: `tests/cli/dry-run.test.ts` asserts `dry-run` and `policyMatchesEvent` agree, entry for entry, across the 12 `cf3dff51` target spellings (`;`, `|`, `&&`, `(`, and a literal newline in both spaced/unspaced forms, on `git push` and the operator-only kill switch).
1056
+ - **`understanding-before-execution`'s `config.mode` (harness.yaml) was config-drift: it drove prose only, never enforcement** (task `5d73d78d`; fix landed in two rounds, the second a review fix-round closing three HIGH findings against the first). Live repro in an isolated home: `config.mode: grill_me`, an agent report with no `mode:` metadata field, `harness approve understanding` (stdin heredoc) → output read `validation: ✓ fast_confirm report passed structural checks` and persisted `"mode": "fast_confirm"`, silently skipping the `derivedTodos` / `acceptanceCriteria` / `priorArt` sections `grill_me` requires. Root cause, two independent sites: (1) the Claude-runtime `UserPromptSubmit`/`Stop` hooks are bins shipped by `@lannguyensi/understanding-gate` (`understanding-gate-claude-hook` / `understanding-gate-claude-stop`), invoked bare — no `UNDERSTANDING_GATE_MODE` env var — so the package's own env-then-marker-then-`fast_confirm`-default resolver always fell through to its default; (2) `persistStdinReport`'s gap-fill for a report with no declared mode hardcoded the literal `"fast_confirm"`.
1057
+ - Fix: mode resolution is now TWO functions with different contracts (`src/policy-packs/builtin/understanding-before-execution.ts`), not one. `resolveModeFromConfig()` — `config.mode` > default (`grill_me`), **never the env var** — drives the GENERATION path: `resolve()`/`buildHooks` (what `harness apply` bakes into `settings.json` + `instructions.md`) and `registry.ts`'s `resolveBuiltinDefaultConfig` (`harness doctor`'s UX-drift comparison, `harness pack reseed`). `resolveMode()` — `UNDERSTANDING_GATE_MODE` env > `config.mode` > default — is reserved for LIVE runtime consumers that resolve at invocation time with the real process environment available: `approve/understanding.ts`'s stdin-report gap-fill, and the Codex `UserPromptSubmit` injector (`harness pack hook codex-user-prompt-submit`). The first round shipped a single env-aware resolver for BOTH roles, which meant an operator's ambient `UNDERSTANDING_GATE_MODE` (exported for an unrelated one-off override, or left over from a previous shell) would silently override `config.mode` in the GENERATED artefact itself — persisting, frozen, until the next `apply`, and making `doctor`/`reseed` report drift that had nothing to do with `harness.yaml`. `buildHooks` still bakes the (now config-only) resolved mode into the two npm-backed Claude bin commands via the same env var (`wrapMode`), but omits the prefix entirely when the resolved mode already coerces to the package's own default (`fast_confirm`) — baking it unconditionally (the first round's behavior) made the package's own in-prompt `/grill` / "grill me" per-prompt escalation marker permanently dead, since the package's own resolver checks its env var before ever looking at the marker.
1058
+ - Fix: `harness approve understanding`'s stdin-report path now genuinely REFUSES the approval marker when the submitted report is rejected under the resolved mode (e.g. a `fast_confirm`-shaped report submitted against a `grill_me`-configured host) and no report matching this session id exists — no marker, no ledger tag, no report flip, unless `--force` (mirroring the pre-existing `priorArt`-validation short-circuit). The first round resolved the correct mode but a rejected stdin submission with no fallback report still fell through to a `{ skipped: true }` validation outcome (not `ok: false`), so the marker was written anyway — a config-drift-shaped hole reopened one call deeper than the one this task closes. A second review round (verification round 2) closed a further gap in the same guard: a sessionId-null pending report ADOPTED via the tolerant fallback (not a strict sessionId match) does not count as "matching this session id" either, so a rejected submission can no longer silently ride on an unrelated leftover report's already-valid content and get stamped with the live session's id.
1059
+ - `strict` has no upstream equivalent (the package's own mode type is two-valued); `toPackageMode()` coerces it to `grill_me` — the closest available rigor — only at that interop boundary, not inside harness's own `strict` semantics.
1060
+ - **Behavior change, corrected blast-radius statement (the first round's was inaccurate — it implied only an EXPLICITLY-configured non-`fast_confirm` host was affected):** the mode actually enforced now matches `config.mode` in all three cases — `config.mode: fast_confirm` (was already effectively `fast_confirm`, unaffected save for the marker-liveness fix above), `config.mode: grill_me` / `strict` (now genuinely enforced, was silently `fast_confirm`), **and no `config:` block / no `mode:` key at all** (now genuinely enforced at `grill_me`, the package default — this last case is the WIDEST blast radius of the three and was NOT called out by the first round's changelog entry: every unconfigured host is affected, not only hosts with an explicit setting). Previously-accepted reports missing `derivedTodos` / `acceptanceCriteria` / `priorArt` are now rejected until the agent supplies them, for any host in the latter two buckets.
1061
+ - New/updated coverage: `tests/policy-packs/resolve-mode.test.ts` (both resolvers' contracts, LOW-8/LOW-9 warning + normalisation fixes), `tests/policy-packs/expand.test.ts` and `tests/cli/apply/apply.test.ts` (generation-path env-independence, the omitted-prefix case), `tests/cli/approve-understanding.test.ts` and `tests/cli/approve-stdin-report.test.ts` (mode gap-fill end to end, the stdin-rejection marker-refusal enforcement + its `--force` escape hatch, the disabled-pack LOW-10 case), and a new suite-wide `tests/_helpers/scrub-ambient-env.ts` setup file (MEDIUM-5: an ambient `UNDERSTANDING_GATE_MODE` in the operator/CI shell no longer leaks into tests that don't explicitly set it).
1062
+ - **`harness approve understanding` could persist a self-contradictory report: `approvalStatus: "approved"` alongside a stale `expiredAt` timestamp** (task `646b7b6a`, reviewer finding on `agent-grounding` `5120938c`, the harness-side twin of that repo's `withApprovalStatus` fix in PR #173). `rewriteReportApproved` (`src/cli/approve/understanding.ts`) rewrote `approvalStatus` to `"approved"` in place without clearing `expiredAt`; since `@lannguyensi/understanding-gate` 0.4.11 declared `expiredAt` in the report schema, the field survived the rewrite whenever an operator approved a report that `expirePersistedReport` (`understanding-before-execution-runtime.ts`) had previously expired. Not a gate bypass — enforcement only reads `approvalStatus` — but a data-integrity defect in the persisted audit record. Fix: `rewriteReportApproved` now `delete`s `expiredAt` from the parsed report before serializing, mirroring the package's own `withApprovalStatus` idiom. New test in `tests/cli/approve-understanding.test.ts` covers approving a report fixtured exactly as `expirePersistedReport` writes one (`approvalStatus: "expired"` + ISO `expiredAt`), asserting the key is absent after approval.
1063
+
1064
+ ### Added
1065
+
1066
+ - **Release-pipeline guards: CI now fails fast when a commit since the last
1067
+ tag has no `[Unreleased]` changelog entry, and release.yml aborts instead
1068
+ of shipping an empty release body** (task `3ed3d333`). New
1069
+ `scripts/check-changelog-coverage.mjs` (`npm run
1070
+ check:changelog-coverage`, wired before Build in ci.yml in the
1071
+ check-no-only idiom) compares `git log <last-tag>..HEAD` against the
1072
+ `[Unreleased]` section: a commit counts as covered when the section cites
1073
+ one of its mechanical link tokens (an 8-hex task id from the commit
1074
+ message, the squash-merge `#PR` number, a GHSA advisory id, or the
1075
+ commit's own SHA); commits typed `test`/`docs`/`ci`/`style`/`refactor`/
1076
+ `chore` are skipped by a convention documented — with its limits — in the
1077
+ script header. Verified against the historical failure: a checkout of
1078
+ `5b7a36f` (pre-0.42.0) goes red with 14 uncovered commits (including the
1079
+ postcss HIGH advisory close), while this branch is green; the gate's
1080
+ first live run also caught the missing `f13c6fbd` doctor entry backfilled
1081
+ under Fixed above. release.yml additionally hard-stops after the awk
1082
+ extraction when `release_notes.md` came out empty or whitespace-only
1083
+ (the "tag pushed before converting [Unreleased]" failure mode), naming
1084
+ the missing `## [<version>]` section in the error.
1085
+ - The `toolchain-parity` ledger fact now carries unparseable peer snapshots
1086
+ instead of silently absorbing them into a shrunken comparison (task
1087
+ `690fba7c`, consumer hardening motivated by the agent-memory `06d09cde`
1088
+ incident where a corrupted `machine-state/<profile>.json` — inline git
1089
+ conflict markers, not valid JSON — made the drift count quietly fall from
1090
+ `drift:22` to `drift:5` with no warning). A peer file that fails
1091
+ `parseSnapshotJson` now also logs a `parity:unparseable-peer:<file-derived
1092
+ label>` stderr line, and the run's ledger fact gains a
1093
+ `:unparseable-peer:<n>` suffix (e.g. `toolchain-parity:drift:7:unparseable-peer:2`)
1094
+ whenever one or more peer files were skipped this way, so an unparseable
1095
+ peer never makes `drift:N` look like a complete comparison. (Peers whose
1096
+ file cannot be READ at all are a separate branch scoped to task
1097
+ `c1b5ade5`; the absence of the suffix alone therefore does not prove
1098
+ completeness.) The file-derived label is sanitized before interpolation —
1099
+ peer filenames arrive cross-machine via sync and are untrusted, so a
1100
+ crafted name cannot forge a standalone parity line in stderr — and falls
1101
+ back to the raw filename when stripping `.json` would leave it empty. The
1102
+ existing "peer snapshot `<file>` is corrupt" warn line is unchanged;
1103
+ valid-only peer sets keep the exact pre-existing `toolchain-parity:ok` /
1104
+ `toolchain-parity:drift:<n>` format (no behaviour change). Four new tests
1105
+ (negative control, corrupt-peer-plus-drift, multi-peer aggregation
1106
+ pinning `:unparseable-peer:2`, hostile-filename sanitization) plus two
1107
+ existing cases extended.
1108
+ - Both PreToolUse hooks (Claude and Codex) now tell a blocked agent WHICH
1109
+ Understanding Report sections were malformed on their last attempt: a
1110
+ `(list)` heading that was present but held prose instead of markdown
1111
+ list items, with an agent-facing sentence naming each section as
1112
+ `<Display Name> (<key>)` (e.g. `Prior Art (priorArt)`), paired with
1113
+ `renderReportSchemaHint`'s existing display-name vocabulary rather than
1114
+ the bare camelCase key the parser itself uses (task `823837fd`, review
1115
+ Fix 3). The sentence is only appended when the session has NO persisted
1116
+ report at all, never when a fixed report is merely pending operator
1117
+ approval (review Fix 1), and is defined once and shared by both hooks
1118
+ via `renderMalformedSectionsNotice` (`src/cli/approve/understanding.ts`,
1119
+ review Fix 4) instead of two byte-identical copies.
1120
+ - `harness approve understanding`'s one-line parse-error summary
1121
+ (`findLatestParseError`) likewise names malformed sections when the
1122
+ operator-facing reason is not already sourced from the package's own
1123
+ `message` field, which annotates them inline since
1124
+ `@lannguyensi/understanding-gate` 0.4.10 (review Fix 5).
1125
+ - `@lannguyensi/understanding-gate` lockfile refreshed to 0.4.10 (the
1126
+ `malformedSections` `ParseError` field this surfacing depends on);
1127
+ the declared range stays `^0.4.9`.
1128
+ - **`harness doctor` now warns (advisory, never an error) when
1129
+ `UNDERSTANDING_GATE_MODE` is set in the operator's shell environment and
1130
+ diverges from `policy_packs[understanding-before-execution].config.mode`**
1131
+ (task `24abdecb`, reviewer finding on the `5d73d78d` Batch 11
1132
+ verification round). After that task's fix, only the LIVE runtime path
1133
+ (`resolveMode` in `approve/understanding.ts`'s stdin-report gap-fill, and
1134
+ the Codex `UserPromptSubmit` injector) reads the env var at all — the
1135
+ GENERATION path (`resolve()`/`buildHooks`, what `harness apply` bakes
1136
+ into settings.json) never has and still doesn't. An operator who carries
1137
+ `UNDERSTANDING_GATE_MODE` in their shell profile therefore silently
1138
+ downgrades live enforcement relative to what `harness.yaml` declares,
1139
+ with no signal anywhere that the two have diverged — live-verified:
1140
+ `harness approve understanding` accepted a `fast_confirm`-shaped report
1141
+ against a `grill_me`-configured manifest with no warning. New
1142
+ `checkUnderstandingModeEnvDivergence` (`src/cli/doctor/understanding-mode-env.ts`)
1143
+ is a pure manifest+env check, gated on the pack being declared and
1144
+ enabled, normalising the env value the same way `resolveMode` does
1145
+ (trim + lowercase) so an env value the live resolver would itself reject
1146
+ is correctly treated as non-divergent; the advisory names both the env
1147
+ value and the resolved `config.mode` and renders in doctor's
1148
+ `Environment` section (mirroring the existing npm-global-bin-off-PATH
1149
+ advisory there). `harness doctor`'s existing `envOverride` test-injection
1150
+ knob is reused rather than adding a second one. New coverage in
1151
+ `tests/cli/doctor-understanding-mode-env.test.ts`: the pure function's
1152
+ unset/empty/agreeing/invalid/undeclared/disabled no-advisory cases, the
1153
+ diverging-value case (naming both values), and a `doctor()`-level
1154
+ integration pass through `format()` asserting the rendered `Environment`
1155
+ section and `warningCount`.
1156
+ - **`harness apply --runtime opencode`: a third policy-pack adapter
1157
+ runtime alongside `claude-code` and `codex`** (task `f34eb233`).
1158
+ New `src/cli/apply/generate-opencode-config.ts` emits
1159
+ `harness.generated/opencode/opencode.json` in place of settings.json,
1160
+ carrying an ADR-style header documenting the adapter-mapping decisions
1161
+ verified against opencode's published config JSON Schema and docs:
1162
+ `tools.mcp[]` IS projected (opencode's native `mcp` block, reusing
1163
+ `generate-settings.ts`'s `buildMcpServers` / `projectGroundingEnv` so
1164
+ the grounding-mcp `EVIDENCE_LEDGER_DB` wiring and the literal-tilde
1165
+ warning stay identical across every runtime); manifest `hooks[]` and
1166
+ `memory.router` are NOT projected (opencode has no declarative
1167
+ hook/event field at all, only a JS/TS plugin API — out of this task's
1168
+ scope), and opencode's native `permission` block is deferred to a
1169
+ follow-up (v1 keeps parity with the codex adapter's own deferred
1170
+ sandbox-profile mapping), both cases surfacing a warning instead of a
1171
+ silent drop. Output is byte-stable across repeat applies and harness
1172
+ never merges into, or writes, any operator-owned opencode config file
1173
+ (`~/.config/opencode/`, a project `opencode.json`, or `$OPENCODE_CONFIG`)
1174
+ — the generated artefact's own banner explains how an operator wires it
1175
+ in. `--target <path>` is rejected with `--runtime opencode`, symmetric
1176
+ to the existing `--runtime codex` rejection (both wire Claude Code's
1177
+ settings.json). `harness doctor --target opencode`
1178
+ (`src/cli/doctor/opencode.ts`) checks the generated artefact's
1179
+ presence + banner and that every projected MCP server's command
1180
+ resolves on PATH; `countStatusDiagnostics` is factored out of
1181
+ `doctor/codex.ts`'s `countCodexDiagnostics` so both target modules
1182
+ share one tally loop instead of two copies. New coverage:
1183
+ `tests/cli/apply/generate-opencode-config.test.ts`,
1184
+ `tests/cli/apply/apply-opencode-runtime.test.ts`,
1185
+ `tests/cli/doctor-opencode.test.ts`.
1186
+ - **`harness apply --runtime opencode` review fix-round** (task
1187
+ `f34eb233`): addresses five findings from the batch18 review of the
1188
+ opencode adapter above. HIGH: `branch-protection` /
1189
+ `understanding-before-execution` / `solution-acceptance` /
1190
+ `post-merge-gate`'s `buildInstructions()` each branched only on
1191
+ `runtime === "codex"`, so opencode silently fell through to the
1192
+ claude-code `else` branch and its `instructions.md` falsely claimed
1193
+ hooks were "wired into the harness-managed settings.json" even though
1194
+ nothing is wired for opencode (no settings.json exists, and
1195
+ `packExpansion.hooks` is never projected into the opencode artefact).
1196
+ Each pack's instructions now mark opencode UNSUPPORTED/not-wired,
1197
+ mirroring `post-merge-gate`'s existing codex marker;
1198
+ `post-merge-gate`'s pack-level apply warning widened from
1199
+ `runtime === "codex"` to `runtime !== "claude-code"` so opencode gets
1200
+ it too; `apply.ts`'s stale "pack instructions.md are runtime-agnostic"
1201
+ comment corrected. MED: added a golden `toBe()` test over
1202
+ `generateOpencodeConfig`'s full rendered output (a manifest with mcp +
1203
+ hooks + a policy-pack-shaped hook + memory.router, deterministic
1204
+ `homeDir`) — the prior substring-only assertions missed a
1205
+ deleted/garbled line inside the generator's static HEADER banner.
1206
+ `src/cli/init/interactive.ts`'s three stale "disabled until f34eb233
1207
+ lands" comments/messages reworded: the adapter has shipped, wizard
1208
+ wiring is tracked separately as installer v1.1 task `c5287b80`;
1209
+ `docs/for-agents.md`'s CLI cheat sheet gains the missing
1210
+ `doctor --target opencode` row. LOW: a `tools.mcp[]` entry declared
1211
+ `enabled: false` is now projected into the opencode `mcp` block as an
1212
+ explicit `{"enabled": false}` marker instead of being omitted --
1213
+ opencode MERGES multiple config sources, so an omitted key does not
1214
+ override an active declaration for the same server name elsewhere,
1215
+ while opencode does accept a bare `{"enabled": false}` entry;
1216
+ `harness doctor --target opencode`'s MCP command-resolution check
1217
+ skips the PATH lookup for such a marker instead of reading its missing
1218
+ `.command`. `countStatusDiagnostics` and its status type moved out of
1219
+ `doctor/codex.ts` into new `doctor/target-checks.ts` (re-exported from
1220
+ `codex.ts` for compatibility) -- `codex.ts` was not a natural home for
1221
+ a target-agnostic tally helper other adapters also depend on.
1222
+
1223
+ ## [0.44.0] - 2026-08-03
1224
+
1225
+ ### Security
1226
+
1227
+ - **SECURITY: `${REPO}`/`${BRANCH}`/`at_head` now resolve PER POLICY to the repository a gated command actually touches, closing the cross-repo defect where a preflight fact recorded for repository A authorised an investigative git read (or, chained, a mutation) of a DIFFERENT repository B** (task `98ad072f`). Measured and closed: `preflight-before-investigation`'s cwd-only resolution (unchanged since 0.42.0, explicitly named as future work — follow-up `98ad072f` — in the 0.43.0 entry below) let `git -C <B> status` from cwd A be authorised by A's own `preflight:${REPO}` fact even though the read genuinely runs against B; `preflight-before-push` had the mirror gap for `${BRANCH}`/`at_head`. `src/runtime/command-normalize.ts` gains a per-segment view (`segmentViewOf`/`CommandSegment`: own explicit target + composed effective target); `src/runtime/intercept.ts`'s `resolveAttributedContexts` uses it to evaluate a `${REPO}`/`${BRANCH}`-bearing policy once per DISTINCT repository context a trigger-satisfying segment names, not once per event.
1228
+
1229
+ **ACCEPTED SEMANTIC CHANGE (operator decision D-021, after a four-pass halt — see below):** attribution is UNIVERSAL-ADDITIVE, never a replacement. The cwd context is demanded UNCONDITIONALLY on every event a per-repo-builtins policy matches; each distinct attributed target (a segment's own `-C`/`env -C`/`--git-dir`, or a target inherited from a preceding `cd`) ADDS its own demand alongside cwd's, never in place of it. Concretely: **`git -C <B> push` from cwd A now demands BOTH A's and B's preflight evidence — holding only B's tag no longer authorises reading or mutating B from a different cwd.** This is more restrictive than the shipped, cwd-only engine (never weaker, by construction — see the matrix below), but it is a real behaviour change an operator upgrading should know about: a workflow that legitimately does `git -C <B> ...` from a different repository's checkout now needs a preflight recorded in EACH repository it touches, not just its own cwd.
1230
+
1231
+ **Why additive, not "attribute to the segment's own target and stop" (the design this task shipped and then reverted four times):** an own-target REPLACE design — trust a segment's own `-C`/`--git-dir` as proof the invocation runs there, and evaluate ONLY that context — was built, reviewed, and found to reopen the exact class of bug this task exists to close, four separate times across four independent adversarial review passes, each measured against the real shipped binary and (where relevant) real git: (1) an inherited `cd` basis carried into a segment bash does not actually run in (a subshell close, a bare pipe stage, `cd -`, `pushd`) let a forged `.git/HEAD` directory redirect the tag; (2) `--work-tree=<B>` was folded into the same own-target bucket as `-C`/`--git-dir` though it does not relocate the git-dir, so `git --work-tree=<B> push` still runs in the CWD repo but was attributed to B; (3) more than one repo-relocating option (`git -C <forge> -C <realA> push`) was attributed to the FIRST one seen while real git composes cumulatively and runs in the LAST; (4) a tilde-valued second `-C` (`git -C <decoy> -C ~/sub push`) was invisible to (3)'s own ambiguity counter, so the ambiguity lock never engaged. Each was fixed individually and confirmed sound by every later pass; the RECURRENCE — four distinct instances of one class — is what this repo's structural-regression-halt convention exists to catch, and the fourth instance triggered exactly that halt (nothing merged at that point; the operator was handed the full four-pass history rather than a fifth "surely now complete" patch). The chosen fix makes the property true BY CONSTRUCTION instead of by enumerating git's composition knobs: since the cwd demand is never dropped, no gap in the static, string-only shell/git model — known or not yet found — can make a gate weaker than the shipped cwd-only engine. `03-decisions.md` D-011/D-017/D-018/D-019/D-020/D-021 in `.ai/runs/2026-08-02-per-repo-gate-scoping-redesign/` carries the full per-pass history.
1232
+
1233
+ **Measured, not asserted, that this holds:** `scripts/measure-additive-attribution-matrix.mjs` (`npm run measure:additive-attribution-matrix -- --control <dir containing an installed 0.43.0 build>`) runs a differential matrix of 6 separators (`&&`, `;`, newline, `|`, `||`, subshell) x 8 command forms (spanning every one of the four historical bypass classes above, plus a legitimate single-`-C` case and two no-target baselines) = 48 cells, each with a ledger holding ONLY a forged/decoy repository's evidence (the cwd's own is always absent), run against BOTH this engine and the real, installed 0.43.0 binary as the shipped oracle. Result: **0/48 cells weaker than 0.43.0** — every cell's cwd-tagged decision is identical to what 0.43.0 itself computes for that same command, and 21/48 cells additionally exercise the additive branch (a genuine second, foreign context demanded alongside cwd's). The instrument's own self-test sabotages those 21 cells to pre-fix REPLACE semantics post-hoc and confirms the audit catches all 21/21 — proof the "0 weaker" result is a real measurement, not a referee that cannot fail.
1234
+
1235
+ **The bound (D-013):** distinct attributed contexts for one policy on one event are capped at 4 (`MAX_ATTRIBUTED_CONTEXTS`); a command naming more fails CLOSED — one synthetic decision naming the ambiguity, ZERO ledger queries for that policy — respecting the policy's own `enforcement:` (a `block` policy denies, a `warn` policy warns, never a hardcoded outcome), rather than silently evaluating all of them (the measured amplification this bound closes: K distinct targets against N per-repo policies previously cost N×K ledger queries and audit writes per event, unbounded).
1236
+
1237
+ **What this does NOT close, verbatim, so no doc or commit overclaims it:**
1238
+ - A same-invocation composition this module cannot resolve to a single, unambiguous target (more than one repo-relocating option, or `--work-tree` alone) still falls back to cwd-only — precisely the shipped 0.43.0 behaviour for that shape, not a new gap. D-017/D-018 stay in place as PRECISION (avoiding a spurious extra demand), no longer as the security mechanism — D-021's additive backstop is what carries safety now.
1239
+ - The tilde-valued relocating-option residual named above (bypass 4) is NOT fixed at the counting layer — a tilde-valued second `-C` still attributes an own target the way it did pre-fix. Under D-021 this is now SAFE (the cwd demand is added, never dropped) but SPURIOUS: the command also picks up an extra, over-restrictive demand it should arguably not owe. Fail-closed, spurious-but-safe over-demand, not a bypass — a precision follow-up, not a blocker.
1240
+ - `${BRANCH}` is still a bare branch name with no repository qualifier, so a same-named branch in two different repositories can still satisfy each other's tag — unchanged, independent of attribution.
1241
+ - The static-analysis ceiling every prior entry in this file already names is unchanged and applies here too: a heredoc body, `sh -c '...'`, `eval`, or a base64-decoded payload that constructs a targeted git invocation at runtime is invisible to this (or any) string-level normaliser; such a command still evaluates against cwd only, same as 0.43.0.
1242
+ - The `resolveGitContext` primitive this whole mechanism (and 0.42.0's cwd-only resolution before it) rests on is a filesystem-shape check, not a git-validated one: any directory containing a `.git/HEAD` file is a taggable, agent-forgeable repository identity. Additive attribution neutralises the SPECIFIC exploit path this task closes (an agent redirecting which tag is demanded away from the repo a verb truly targets); it does not change this underlying primitive.
1243
+
1244
+ New tests: `tests/runtime/intercept-cli.test.ts` gains the T-001 regression pins (the three 07-27 defects, mandatory), the T-002/T-003 per-segment/per-policy attribution suite, the D-011..D-021 hardening suite (inherited-`cd` additivity, `--work-tree` exclusion, multiple-`-C` ambiguity, the tilde residual, the context bound, dedup, operator-override-in-an-attributed-context, symlink realpath identity), and — this documentation round — a `findGitEntry` upward-walk collapse pin (a relative `-C <subdir-of-cwd-repo>` with no `.git` of its own is ONE cwd decision, not two) and a bound-under-`warn` pin (more than 4 distinct targets on a `warn`-enforcement policy resolves to `warn`, never `deny`, respecting `outcomeForFailedRequires`). `resolverGit`/`riskContext` (the Risk Gate path) is byte-identical across every commit on this task — the 07-27 Risk Gate declassification regression cannot recur here. Docs: `docs/writing-custom-policies.md` (a new section on the per-policy `${REPO}`/`${BRANCH}`/`at_head` resolution and its fallback list), `docs/okf/gate-fail-posture-matrix.md` (the additive fallback posture).
1245
+
1246
+ - **SECURITY: post-merge-gate blocker precedence reversed — deny now wins over the escape allowlist, closing the chained-escape bypass where `harness preflight && git push origin master` (the documented preflight-then-push workflow) skipped the gate entirely** (task `19356be7`). `isEscapeCommand` was an OR over the WHOLE command string and `hook-post-merge-gate.ts` checked it first and unconditionally, so any chained command containing an escape verb anywhere (`git switch`, `git stash list`, any `harness ...`) fell out of the gate including its mutation half. The blocker now classifies via the new `isGateEligibleCommand` (post-merge-gate-runtime.ts): gate-eligible iff the curated mutation matcher fires after quoted-heredoc bodies attached to a harness invocation are stripped. **Measured before choosing (66-row corpus, bash ground truth via PATH stubs; `.ai` run `2026-08-02-post-merge-gate-escape-precedence`):** per-segment evaluation and deny-wins are 0-divergent — a consequence of the pinned escape/deny verb disjointness — so the simplest (deny-wins) shipped. The third candidate, escape-at-start-only, was measured and REJECTED: it diverges on 26 rows and still exempts three of the four lines this task exists to close. Against the shipped classifier: **0 false-frees, 0 recovery-chain lockouts** (`git switch <default> && git pull --ff-only` stays free structurally, allowed before any manifest/ledger access), **0 commands newly freed**, and 28 newly gate-eligible = 22 bash-confirmed real mutations + 6 over-blocks. **The 6 over-blocks are the honest cost, listed rather than summarised:** four are the existing quoted-text class (`git pull && echo 'a && git push'` and friends, no longer rescued by a chained escape verb), and two are harness report heredocs whose BODY mentions a mutation verb (`harness approve understanding <<'…'`). Gate-eligible is not blocked: a deny still needs the branch tip to equal a recorded merged tip, and the gate's own recommended recovery stays free.
1247
+
1248
+ **Explicitly NOT done, after two review rounds:** an earlier iteration pre-processed the command to strip quoted-heredoc bodies fed to a harness invocation, so the two over-blocks above would not occur. Round 1 found six shapes where that stripping consumed lines bash really executes; the quote-aware scanner written to fix them was found by round 2 to have twelve more of the same class. Both families share one cause — deciding where a heredoc body begins means re-deriving bash's quote and word grammar on the permissive side of a security boundary — and tasks `dbc6d303` and `5b1b24fb` had already been halted for it. The mechanism was therefore removed rather than patched a third time, and the two over-blocks are accepted and pinned. Task `5b1b24fb` owns the real fix, at the `bash_match` layer for every surface at once. Escape regexes stay exported as deny-message vocabulary and diagnostics. Pack instructions, `docs/policy-packs/post-merge-gate.md`, the blocker hook's `description`, `harness pack hook post-merge-gate --help`, the generated-manifest comment in `harness init --template full`, and `docs/examples/full-manifest.yaml` all updated to stop teaching escape-first, with drift pins on each of those operator-visible surfaces.
1249
+
1250
+ - **SECURITY: the ampersand-`&`-boundary gap `BOUNDARY_RE` deliberately left open (0.43.0's `d834a065` entry above) is now closed by a SECOND, independent normalisation pass instead of widening the first, which was measured and reverted for a 140/140 regression** (task `aabbad63`). `src/runtime/command-normalize.ts` gains `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) driving `normalizeCommandAmpAware`, consulted by `policyMatchesEvent` (`src/runtime/intercept.ts`) as a THIRD, additional arm — tried only when a policy's regex has already missed both the raw command and the primary (`BOUNDARY_RE`) normalised form, so the fix is strictly additive on top of the two existing arms and never a replacement for either. This closes the family `BOUNDARY_RE`'s own comment named as out of that task's budget: `A=x&env -C /tmp git status` (glued, no space) and `echo hi & nice git status` (a genuine bash background job). **Measured, corrected numbers (review round 1 found the shipping build's own closure claim wrong):** over 7 wrappers (`env`/`nice`/`sudo`/`command`/`setsid`/`stdbuf`/`nohup`) × 4 gated verbs × 2 shapes (`A=x&<wrapper> <verb>`, `echo hi & <wrapper> <verb>`) = 56 realistic spellings, 28/56 were ALREADY gated before this change (raw or the primary normalised arm), 52/56 gate after — a delta of 24 attributable to this arm alone — and 4 remain ungated, all `echo hi & nohup <gated verb>` (`nohup` is not one of `canonicalizeSegment`'s recognised wrapper names, a pre-existing, already-documented module-header gap — not something either boundary alphabet can reach, pinned in `tests/runtime/command-normalize.test.ts`). The pre-change 28/56 is high because the shipped trigger regexes' own `(\w+=\S+\s+)*` leading-assignment group lets `\S+` swallow a glued `&<wrapper>` (`A=x&nice git push` already matched the RAW regex before this task ran at all), so the GLUED family was never actually what needed closing — the background-job family (`echo hi & <wrapper> <verb>`) is. `AMP_BOUNDARY_RE`'s alternation order (`&&` left of bare `&`) is load-bearing for JS's leftmost-first alternation and is pinned by execing a clone of the exported regex directly (fix round 1, finding F9 — the original pin exec'd the shared, exported `/g` object itself, leaving `lastIndex` mutated for whatever ran next), since the ordering is invisible in this module's own segment-and-rejoin output string — verified by swapping the order and diffing output, byte-identical either way. **New false-positive class, widening an already-accepted one, not introducing a new one:** on a 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed verbs + a marker file), 144 spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string now match where the base build did not, and bash ran the verb in 0 of them — but the wrapper-LESS variant of the same shape (`echo 'a & git push'`) already false-alarmed on the base build 16/16 via the RAW regex alone, since every shipped trigger already anchors on a bare `&` (task `d834a065`); this widens that already-accepted over-block class to the wrapped spellings rather than introducing a new one. The opposite direction, on the general (non-adversarial) corpus: of 276 matches this pass gains there, all 151 classifiable ones were TRUE positives, 0 false alarms. Engine-side fix: applies on package upgrade, no manifest change required. **Fix round 1 also closed three inert/misleading test and comment surfaces found by review, none of them behavioural:** (1) the CLI-side memoising thunk (`ampNormalizedCommandThunk`, `src/cli/policy/intercept.ts`) and its production wiring into `intercept()` had zero test coverage that actually drove an event through `runInterceptCli` — the existing "memoising thunk" test built its own closure and proved only that a memoising closure memoises; a new `runInterceptCli`-level test (counting real `normalizeCommandAmpAware` calls via a call-through mock) now goes red under either of two measured mutations (replacing the `??=` memoisation with a plain call; deleting the thunk's spread into `intercept()` entirely) that previously left the whole suite green. (2) That same once-per-event discipline never covered a Codex shell event (`tool_name: "shell"` / `"exec_command"` / `"functions.exec_command"`) whose command lived under `raw_input`/`input` or a `cmd` key: the CLI wrapper built the precompute and the thunk from `event.tool_name === "Bash"` + `tool_input.command` only, while the matcher itself (`extractShellCommand`) reads all three input locations and either key — so such an event ran BOTH normalisation passes once PER MATCHING POLICY instead of once per event. Now derived from `extractShellCommand(event)` directly (guarded by the existing `isCodexShellTool`), which also makes the precomputed value and the matcher's own value provably consistent for every event in `SHELL_ALIASES`. (3) `findNextBoundary`'s `lastIndex` reset was documented as preventing the two passes from clobbering each other's scan position, which measured false (0 output differences across the corpus with the reset removed) — they are two distinct regex objects with nothing to clobber; reworded to the actual reason (throw-safety for a hypothetical caught-and-resumed scan) and marked unreachable under today's control flow, since nothing in this module catches mid-scan and resumes. New tests: `tests/runtime/command-normalize.test.ts` (the still-ungated `nohup` residual pin, the alternation-order clone fix), `tests/runtime/intercept-cli.test.ts` (the `runInterceptCli`-level once-per-event pin for both a Bash and a Codex shell event). Docs: `docs/okf/policy-engine-producer-wiring.md`, `docs/okf/debug-verb-selection.md`, `docs/okf/gate-fail-posture-matrix.md` (a new row/paragraph for the amp pass's identical `MAX_NORMALIZE_LENGTH` fail-open).
1251
+
1252
+ - **SECURITY: the four surfaces `0.43.0`'s `d834a065` entry named as "deliberately left on the old alphabet" now recognize a bare `&` as a command boundary too, closing follow-up `76671e5a`** — `CURATED_MUTATION_BASH_RE` and `GH_PR_MERGE_BASH_RE` (`src/policy-packs/builtin/post-merge-gate-runtime.ts`), `DEFAULT_PUSH_BASH_RE` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`), and this repo's own `dogfood/harness.yaml`'s `dogfood-recency` trigger. Same fix as `d834a065`: `&&` is subsumed rather than dropped (in `A=x&&git push` the second `&` itself serves as the boundary), so the change is strictly more permissive. **Loss measured with a passing positive control, so the zero means something:** over a 49,999-string corpus, 0 of the old alphabet's matches were lost on any of the four surfaces, all four gained real, bash-verified true positives, and a narrowing control (reverting the fix) reintroduced losses on the same corpus, proving the instrument can actually detect a regression rather than being saturated. No lockout of the documented recovery path either: across 5,258 two-segment and 5,880 three-segment recovery-then-deny commands, the broadened deny side never also matches as an escape command. **New, ACCEPTED false-positive class, widening the one `d834a065` already disclosed for `&&`:** a gated verb mentioned as TEXT after a literal `&` inside a quoted string now matches on all four surfaces too (e.g. `echo "a&git push"`, `grep -F 'x&git commit' notes.md`) — verified against the live PATH that bash never runs the gated verb in any of them, so this is an over-block, not an under-block; pinned in `tests/policy-packs/post-merge-gate-runtime.test.ts` rather than left unrecorded, matching the standard `d834a065` set. **Deliberately left narrow, not swept:** `ESCAPE_GIT_BASH_RE` / `ESCAPE_HARNESS_BASH_RE` (same file) stay on the old, `&&`-only alphabet — they are ALLOW-lists checked first and unconditionally, so widening their boundary alphabet would LOOSEN a gate instead of hardening it, the same reasoning `0.43.0` already gave for not sweeping them. Consequence, measured rather than assumed: 480 of the 5,880 three-segment corpus commands, all of exactly one shape (`<LEAD> & <RECOVERY> & <DENY>`, e.g. `sleep 0&git switch master&git push`), become newly blocked, because the recovery verb is reachable only via the bare `&` the narrow escape check does not recognize. Not a lockout: every such command also contains a mutation verb bash really runs, and the plain recovery alone (`git switch master`, no bare-`&` chaining) is never blocked — pinned as an accepted asymmetry so a future sweep that widens the escape list has to make that decision consciously. **Fix round 1, review findings, none of them behavioural:** a test comment claiming `dogfood/harness.yaml` is a self-inflicted release gate for this repo was false (measured: `harness apply --config dogfood/harness.yaml` exits 1 and rejects it, since the file is a `harness validate`/`harness doctor` fixture, never an applied manifest) and is corrected to describe it as that fixture instead; the source-level allow-list pin that counted `&` characters missed a real widening (`&&?` matches a single bare `&` while keeping the same character count) and is replaced with an exact assertion on the boundary group's source text; the "`&&` stays left of bare `&`" comments on the three runtime regexes overclaimed an ordering requirement that measurably does not hold for a `RegExp.test` matcher (byte-identical match sets swapping to `&|&&` — the segmenter reasoning in `command-normalize.ts` does not transfer here) and are reworded; the dogfood-recency trigger gained a test that actually compiles and fires the pattern (the prior drift guard only string-scanned for the old alternation substring, so a broken regex would have stayed green). New tests: `tests/policy-packs/post-merge-gate-runtime.test.ts` (accepted-over-block pin, escape/asymmetry pin, exact boundary-group pin), `tests/cli/init-full-template-pins.test.ts` (dogfood-recency compiled-trigger pin).
1253
+
1254
+ - **SECURITY: the read-only-bash write-flag guards now decode shell quoting before comparing, closing seven measured fail-opens where a quoted or escaped write flag hid from the guard while bash still honoured it** (slice 1, PR #392). `find . -"delete"`, `find . -'delete'`, `find . -\delete`, `find . -$'delete'`, `find . -de"lete"`, `sort --outp"ut"=out.txt` and `file --"compile"` all ran as "read-only" past the classifier — exact set-membership (`FIND_WRITE_FLAGS.has`) and long-flag prefix tests (`startsWith("--output=")`) were fully defeated, while short-flag cluster tests survived only by accident (the flag letter stayed a substring of the quoted token). New `src/runtime/shell-word.ts` `decodeShellWord` (quote removal + escape decoding only — ANSI-C `\xHH`/`\NNN`/`\uHHHH`, per-quote backslash rules, run concatenation; NO `$VAR`/`$()`/backtick/`~`/glob expansion) is applied before every write-flag comparison. Per its module-header direction rule, the decoded value is used only on the RESTRICTIVE side — to recognise MORE tokens as write flags, never fewer — and `decodeShellWord` returns the raw token for anything it cannot resolve, so an incomplete partial-bash model degrades to today's behaviour, never a new fail-open (the property that distinguishes it from the permissive-side heredoc-strip model removed the same day).
1255
+
1256
+ - **`fast-uri` bumped to a patched release via `npm audit fix`** (lockfile-only, no source change), closing GHSA-7p8r-x3mc-p8w7 (host confusion via a backslash authority introducer; high severity). Transitive build/test dependency, not on the CLI's shipped runtime path.
1257
+
10
1258
  ## [0.43.0] - 2026-08-01
11
1259
 
12
1260
  > **Upgrade note:** the template-level changes in this release — the single-`&` trigger boundary (including the `operator_only` kill-switch deny) and the `mcp__grounding-mcp__` tool-prefix rename — live in the manifest templates, so an EXISTING install keeps its old `harness.yaml` triggers until the operator re-runs `harness init` (or edits the manifest by hand). The engine-side normaliser fixes below apply on package upgrade with no manifest change.