@lannguyensi/harness 0.44.0 → 0.46.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 (234) hide show
  1. package/CHANGELOG.md +1245 -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/apply/next-steps.d.ts +20 -0
  22. package/dist/cli/apply/next-steps.js +60 -0
  23. package/dist/cli/apply/next-steps.js.map +1 -1
  24. package/dist/cli/approve/stdin-report.d.ts +15 -0
  25. package/dist/cli/approve/stdin-report.js +1 -1
  26. package/dist/cli/approve/stdin-report.js.map +1 -1
  27. package/dist/cli/approve/understanding.d.ts +75 -0
  28. package/dist/cli/approve/understanding.js +150 -2
  29. package/dist/cli/approve/understanding.js.map +1 -1
  30. package/dist/cli/audit.d.ts +1 -1
  31. package/dist/cli/audit.js +3 -2
  32. package/dist/cli/audit.js.map +1 -1
  33. package/dist/cli/doctor/claude-mcp.d.ts +19 -1
  34. package/dist/cli/doctor/claude-mcp.js +36 -10
  35. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  36. package/dist/cli/doctor/codex.d.ts +3 -1
  37. package/dist/cli/doctor/codex.js +6 -9
  38. package/dist/cli/doctor/codex.js.map +1 -1
  39. package/dist/cli/doctor/format.js +94 -8
  40. package/dist/cli/doctor/format.js.map +1 -1
  41. package/dist/cli/doctor/index.d.ts +23 -0
  42. package/dist/cli/doctor/index.js +106 -2
  43. package/dist/cli/doctor/index.js.map +1 -1
  44. package/dist/cli/doctor/opencode.d.ts +24 -0
  45. package/dist/cli/doctor/opencode.js +107 -0
  46. package/dist/cli/doctor/opencode.js.map +1 -0
  47. package/dist/cli/doctor/target-checks.d.ts +11 -0
  48. package/dist/cli/doctor/target-checks.js +25 -0
  49. package/dist/cli/doctor/target-checks.js.map +1 -0
  50. package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
  51. package/dist/cli/doctor/toolchain-parity.js +154 -0
  52. package/dist/cli/doctor/toolchain-parity.js.map +1 -0
  53. package/dist/cli/doctor/types.d.ts +85 -1
  54. package/dist/cli/doctor/types.js +1 -1
  55. package/dist/cli/doctor/types.js.map +1 -1
  56. package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
  57. package/dist/cli/doctor/understanding-mode-env.js +83 -0
  58. package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
  59. package/dist/cli/dry-run.js +32 -17
  60. package/dist/cli/dry-run.js.map +1 -1
  61. package/dist/cli/explain.d.ts +1 -1
  62. package/dist/cli/explain.js +1 -1
  63. package/dist/cli/explain.js.map +1 -1
  64. package/dist/cli/index.js +64 -5
  65. package/dist/cli/index.js.map +1 -1
  66. package/dist/cli/init/composer.js +36 -13
  67. package/dist/cli/init/composer.js.map +1 -1
  68. package/dist/cli/init/interactive.d.ts +7 -4
  69. package/dist/cli/init/interactive.js +79 -27
  70. package/dist/cli/init/interactive.js.map +1 -1
  71. package/dist/cli/init/profiles.d.ts +2 -2
  72. package/dist/cli/init/profiles.js +32 -1
  73. package/dist/cli/init/profiles.js.map +1 -1
  74. package/dist/cli/init/templates.d.ts +2 -1
  75. package/dist/cli/init/templates.js +130 -20
  76. package/dist/cli/init/templates.js.map +1 -1
  77. package/dist/cli/pack/approve-escape.js +69 -8
  78. package/dist/cli/pack/approve-escape.js.map +1 -1
  79. package/dist/cli/pack/hook-branch-protection.js +1 -1
  80. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  81. package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
  82. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  83. package/dist/cli/pack/hook-post-merge-gate.js +1 -1
  84. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  85. package/dist/cli/pack/hook-pre-tool-use.js +91 -4
  86. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  87. package/dist/cli/pack/hook-solution-acceptance.js +19 -2
  88. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  89. package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
  90. package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
  91. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  92. package/dist/cli/pause/index.js +1 -1
  93. package/dist/cli/pause/index.js.map +1 -1
  94. package/dist/cli/policy/intercept.d.ts +148 -0
  95. package/dist/cli/policy/intercept.js +379 -48
  96. package/dist/cli/policy/intercept.js.map +1 -1
  97. package/dist/cli/session-start/index.d.ts +12 -3
  98. package/dist/cli/session-start/index.js +67 -11
  99. package/dist/cli/session-start/index.js.map +1 -1
  100. package/dist/cli/session-start/stale-base-check.d.ts +71 -0
  101. package/dist/cli/session-start/stale-base-check.js +379 -0
  102. package/dist/cli/session-start/stale-base-check.js.map +1 -0
  103. package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
  104. package/dist/cli/session-start/toolchain-parity.js +244 -36
  105. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  106. package/dist/cli/smoke/assertions.d.ts +9 -2
  107. package/dist/cli/smoke/assertions.js +9 -2
  108. package/dist/cli/smoke/assertions.js.map +1 -1
  109. package/dist/cli/uninstall/index.js +18 -2
  110. package/dist/cli/uninstall/index.js.map +1 -1
  111. package/dist/cli/validate/checks.d.ts +3 -1
  112. package/dist/cli/validate/checks.js +298 -3
  113. package/dist/cli/validate/checks.js.map +1 -1
  114. package/dist/io/claude-mcp.d.ts +122 -1
  115. package/dist/io/claude-mcp.js +214 -12
  116. package/dist/io/claude-mcp.js.map +1 -1
  117. package/dist/io/duration.js.map +1 -0
  118. package/dist/io/expand-home.js.map +1 -0
  119. package/dist/io/extract.js.map +1 -0
  120. package/dist/io/harness-lock.js +1 -1
  121. package/dist/io/harness-lock.js.map +1 -1
  122. package/dist/{runtime → io}/ledger-record.d.ts +1 -1
  123. package/dist/io/ledger-record.js.map +1 -0
  124. package/dist/policies/index.d.ts +2 -2
  125. package/dist/policies/index.js +2 -2
  126. package/dist/policies/index.js.map +1 -1
  127. package/dist/policies/ledger-client.js +10 -6
  128. package/dist/policies/ledger-client.js.map +1 -1
  129. package/dist/policies/producers.js +1 -1
  130. package/dist/policies/producers.js.map +1 -1
  131. package/dist/policies/requires.js +2 -2
  132. package/dist/policies/requires.js.map +1 -1
  133. package/dist/policy-packs/builtin/branch-protection.js +28 -3
  134. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  135. package/dist/policy-packs/builtin/post-merge-gate.js +45 -9
  136. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  137. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +139 -8
  138. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +246 -12
  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 +112 -0
  190. package/dist/runtime/command-normalize.js +411 -38
  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 +78 -3
  198. package/dist/runtime/intercept.js +151 -39
  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 +303 -24
  203. package/dist/runtime/read-only-bash.js.map +1 -1
  204. package/dist/schema/doctor.d.ts +25 -0
  205. package/dist/schema/doctor.js +23 -0
  206. package/dist/schema/doctor.js.map +1 -0
  207. package/dist/schema/extract.js +1 -1
  208. package/dist/schema/extract.js.map +1 -1
  209. package/dist/schema/index.d.ts +76 -0
  210. package/dist/schema/index.js +9 -0
  211. package/dist/schema/index.js.map +1 -1
  212. package/dist/schema/requires.js +2 -2
  213. package/dist/schema/requires.js.map +1 -1
  214. package/dist/schema/risk.d.ts +7 -0
  215. package/dist/schema/risk.js +23 -0
  216. package/dist/schema/risk.js.map +1 -1
  217. package/dist/schema/stale-base-check.d.ts +18 -0
  218. package/dist/schema/stale-base-check.js +37 -0
  219. package/dist/schema/stale-base-check.js.map +1 -0
  220. package/dist/schema/toolchain-parity.d.ts +3 -0
  221. package/dist/schema/toolchain-parity.js +13 -0
  222. package/dist/schema/toolchain-parity.js.map +1 -1
  223. package/package.json +3 -2
  224. package/dist/policies/duration.js.map +0 -1
  225. package/dist/policies/extract.js.map +0 -1
  226. package/dist/runtime/expand-home.js.map +0 -1
  227. package/dist/runtime/ledger-record.js.map +0 -1
  228. /package/dist/{policies → io}/duration.d.ts +0 -0
  229. /package/dist/{policies → io}/duration.js +0 -0
  230. /package/dist/{runtime → io}/expand-home.d.ts +0 -0
  231. /package/dist/{runtime → io}/expand-home.js +0 -0
  232. /package/dist/{policies → io}/extract.d.ts +0 -0
  233. /package/dist/{policies → io}/extract.js +0 -0
  234. /package/dist/{runtime → io}/ledger-record.js +0 -0
@@ -1,1096 +1,10 @@
1
- // Phase 6 #4 — runtime helpers for the understanding-before-execution pack.
2
- //
3
- // Two-source approval check that the harness-side PreToolUse blocker
4
- // consults:
5
- //
6
- // 1. Filesystem marker `<generatedDir>/.approvals/<sessionId>` written
7
- // by `harness approve understanding` from the operator's shell.
8
- // Canonical for harnessed sessions. Replaces the ledger-substring
9
- // check that shipped through v0.13.0 (agent-tasks/88ca4bb3): the
10
- // agent has direct MCP access to the same ledger that gate path
11
- // consulted, so any agent could write `understanding-approved:<sid>`
12
- // itself and self-approve. Edit / Write / Bash are all gated by
13
- // this same PreToolUse hook, and the configured MCP servers do not
14
- // expose filesystem writes, so the marker file is reachable only
15
- // from a process the operator launched (their `!`-shell or any
16
- // other un-hooked terminal). Operator-side: writeApprovalMarker
17
- // below. Forensics: the ledger row is still written by
18
- // `harness approve understanding`, for audit only.
19
- // 2. Persisted JSON report under `.understanding-gate/reports/`
20
- // (canonical for solo `@lannguyensi/understanding-gate` users).
21
- // The package writes one file per session; the latest with
22
- // `approvalStatus: "approved"` matching the session_id wins. The
23
- // report is flipped to "approved" by `harness approve`; the
24
- // agent's Stop hook only writes `pending` reports and cannot flip
25
- // them (Edit/Write/Bash gated), so this source is also operator-
26
- // authored.
27
- //
28
- // Either source approves. The persisted-report fallback is what makes a
29
- // solo user without grounding-mcp wired still able to approve via the
30
- // package's CLI; the marker path is what makes a harnessed session see
31
- // the approval immediately on the next tool call.
32
- import * as fs from "node:fs";
33
- import * as path from "node:path";
34
- import { atomicWriteFile } from "../../io/atomic-write.js";
35
- import { readRegularFileRejectingSymlink } from "../../io/read-regular-file.js";
36
- import { InvalidDurationError, parseDurationSeconds } from "../../policies/index.js";
37
- import { signMarker, verifyMarkerSignature } from "../../runtime/approval-signing.js";
38
- import { POLICY_DECISION_TYPE } from "../../runtime/ledger-record.js";
39
- import { rejectMalformedSessionId } from "../../runtime/reject-malformed-session-id.js";
40
- import { expandToolNameAliases } from "../../runtime/tool-name-aliases.js";
41
- export const APPROVED_LEDGER_TAG_PREFIX = "understanding-approved:";
42
- export const APPROVAL_MARKER_DIRNAME = ".approvals";
43
- const DEFAULT_REPORTS_DIRNAME = ".understanding-gate";
44
- const REPORTS_SUBDIR = "reports";
45
- /**
46
- * Env var the persisted-report directory can be set from. Honored by
47
- * harness (`defaultReportsDir` below + emitted by `harness apply` onto
48
- * the pack-contributed hook commands) AND by `@lannguyensi/understanding-gate`
49
- * (its `core/persistence.js:resolveReportDir` reads the same name), so
50
- * the three actors that touch the directory — Stop hook (package),
51
- * PreToolUse blocker (harness), `harness approve understanding` — can
52
- * agree on the path regardless of each process's cwd.
53
- */
54
- export const REPORTS_DIR_ENV = "UNDERSTANDING_GATE_REPORT_DIR";
55
- /**
56
- * Resolve the persisted-report directory. Precedence:
57
- * 1. `UNDERSTANDING_GATE_REPORT_DIR` (taken verbatim — apply emits an
58
- * absolute path, operator-exported values are shell-expanded before
59
- * we see them).
60
- * 2. `<cwd>/.understanding-gate/reports` — backward-compat fallback.
61
- * Callers that have a stable anchor (the manifest directory) pass
62
- * it as `cwd` so the fallback agrees with whatever path apply
63
- * baked into the hook commands.
64
- */
65
- export function defaultReportsDir(cwd = process.cwd()) {
66
- const fromEnv = process.env[REPORTS_DIR_ENV];
67
- if (typeof fromEnv === "string" && fromEnv.length > 0)
68
- return fromEnv;
69
- return path.join(cwd, DEFAULT_REPORTS_DIRNAME, REPORTS_SUBDIR);
70
- }
71
- /**
72
- * Project root anchor for the reports directory: `<dir-of-manifest>/.understanding-gate/reports`.
73
- * Used by `harness apply` to bake an absolute, manifest-anchored value into
74
- * the pack-contributed hook commands' env, and by `harness approve` as the
75
- * fallback when `UNDERSTANDING_GATE_REPORT_DIR` is unset.
76
- */
77
- export function reportsDirForManifest(manifestPath) {
78
- return path.join(path.dirname(manifestPath), DEFAULT_REPORTS_DIRNAME, REPORTS_SUBDIR);
79
- }
80
- /** Build the per-session ledger tag the pack searches for. */
81
- export function approvedLedgerTagFor(sessionId) {
82
- return `${APPROVED_LEDGER_TAG_PREFIX}${sessionId}`;
83
- }
84
- function safeJsonParse(text) {
85
- try {
86
- return JSON.parse(text);
87
- }
88
- catch {
89
- return null;
90
- }
91
- }
92
- /**
93
- * Parse the ISO prefix of a producer filename
94
- * (`2026-05-24T06-16-39-409Z-<slug>-<hash>.json`) into epoch ms. The
95
- * producer flattens `:` and `.` to `-` for filesystem safety; undo that
96
- * before `Date.parse`. null when the name does not carry the prefix.
97
- */
98
- function parseFilenameIsoMs(name) {
99
- const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(name);
100
- if (!m)
101
- return null;
102
- const ms = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
103
- return Number.isNaN(ms) ? null : ms;
104
- }
105
- function readPersistedReport(filePath, mtimeMs) {
106
- let raw;
107
- try {
108
- raw = fs.readFileSync(filePath, "utf8");
109
- }
110
- catch {
111
- return null;
112
- }
113
- const parsed = safeJsonParse(raw);
114
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
115
- return null;
116
- const obj = parsed;
117
- const createdAt = typeof obj["createdAt"] === "string" ? obj["createdAt"] : null;
118
- const createdAtJsonMs = createdAt !== null ? Date.parse(createdAt) : Number.NaN;
119
- const createdAtMs = !Number.isNaN(createdAtJsonMs)
120
- ? createdAtJsonMs
121
- : (parseFilenameIsoMs(path.basename(filePath)) ?? mtimeMs);
122
- return {
123
- filePath,
124
- sessionId: typeof obj["sessionId"] === "string" ? obj["sessionId"] : null,
125
- approvalStatus: typeof obj["approvalStatus"] === "string" ? obj["approvalStatus"] : null,
126
- approvedAt: typeof obj["approvedAt"] === "string" ? obj["approvedAt"] : null,
127
- createdAt,
128
- createdAtMs,
129
- };
130
- }
131
- /**
132
- * List persisted reports under `dir`, newest-first by creation time
133
- * (JSON `createdAt`, falling back to the filename ISO prefix, falling
134
- * back to mtime). Missing directory returns []. Any I/O error on a
135
- * single file is silently skipped; the caller falls through to the
136
- * ledger result.
137
- *
138
- * Creation time, NOT mtime, is the sort key: `harness approve
139
- * understanding` rewrites the report it flips, which bumps mtime and
140
- * made an old just-approved report sort as the freshest
141
- * (harness-discovery C1). mtime survives only as the last-resort
142
- * fallback for files that carry neither timestamp.
143
- */
144
- export function listPersistedReports(dir) {
145
- let names;
146
- try {
147
- names = fs.readdirSync(dir);
148
- }
149
- catch {
150
- return [];
151
- }
152
- const reports = [];
153
- for (const name of names) {
154
- if (!name.endsWith(".json"))
155
- continue;
156
- const full = path.join(dir, name);
157
- let stat;
158
- try {
159
- stat = fs.statSync(full);
160
- }
161
- catch {
162
- continue;
163
- }
164
- if (!stat.isFile())
165
- continue;
166
- const report = readPersistedReport(full, stat.mtimeMs);
167
- if (!report)
168
- continue;
169
- reports.push(report);
170
- }
171
- reports.sort((a, b) => b.createdAtMs - a.createdAtMs);
172
- return reports;
173
- }
174
- /**
175
- * Maximum age a sessionId-null report may have for the tolerant
176
- * fallback to adopt it on the `harness approve understanding` path.
177
- * Sized for the real flow (Stop hook persists at turn end, operator
178
- * approves within minutes) with slack for a slow read-through. Live
179
- * repro that motivated it: a 17-day-old pending report got adopted,
180
- * validated, and stamped for a fresh session because the producer had
181
- * silently failed to persist the fresh report (harness-discovery C1,
182
- * friction-log #67).
183
- */
184
- export const TOLERANT_FALLBACK_MAX_AGE_MS = 15 * 60_000;
185
- /**
186
- * Tolerance for a sessionId-less candidate whose `createdAt` lies in
187
- * the FUTURE relative to the approve-time clock. A future creation
188
- * time is suspect either way: a forged `createdAt` (the producer's
189
- * Metadata block lets the agent author it) or serious clock skew.
190
- * Beyond this skew the fallback rejects the candidate just like a
191
- * stale one rather than trusting a timestamp that cannot be right.
192
- */
193
- export const TOLERANT_FALLBACK_FUTURE_SKEW_MS = 5 * 60_000;
194
- /**
195
- * Select the freshest report for a given session_id, or the freshest
196
- * applicable report when the persisted file lacks a sessionId field
197
- * (older package versions). `report: null` when nothing matches.
198
- *
199
- * The strict (sessionId-equals) match always wins. The tolerant
200
- * fallback's appetite is controlled by `opts.tolerantFallback` and
201
- * `opts.maxFallbackAgeMs` — see `FindReportOptions`. The selection
202
- * result carries enough context (`fallbackAdopted`, `staleRejected`)
203
- * for the caller to be loud about non-strict adoptions.
204
- */
205
- export function selectReportForSession(reports, sessionId, opts = {}) {
206
- // Strict match first.
207
- for (const r of reports) {
208
- if (r.sessionId === sessionId) {
209
- return { report: r, fallbackAdopted: false, staleRejected: [] };
210
- }
211
- }
212
- // Tolerant fallback: a report without sessionId is treated as
213
- // applicable to whichever session is asking. Only kicks in when no
214
- // matching sessionId-tagged report exists — which includes the case
215
- // where the producer Stop hook silently failed to persist the live
216
- // session's report, so the candidates here may be entirely unrelated
217
- // leftovers. `maxFallbackAgeMs` is the guard against adopting those.
218
- const mode = opts.tolerantFallback ?? "any";
219
- const nowMs = (opts.now ?? new Date()).getTime();
220
- const staleRejected = [];
221
- for (const r of reports) {
222
- if (r.sessionId !== null)
223
- continue;
224
- if (mode === "uncompleted" &&
225
- (r.approvalStatus === "approved" || r.approvalStatus === "expired")) {
226
- // A completed-cycle report from another session/task; skipping it
227
- // here is what stops `harness approve understanding` from binding
228
- // the live session to a stale, unrelated report.
229
- continue;
230
- }
231
- if (opts.maxFallbackAgeMs !== undefined) {
232
- const ageMs = nowMs - r.createdAtMs;
233
- if (ageMs > opts.maxFallbackAgeMs || ageMs < -TOLERANT_FALLBACK_FUTURE_SKEW_MS) {
234
- staleRejected.push(r);
235
- continue;
236
- }
237
- }
238
- return { report: r, fallbackAdopted: true, staleRejected };
239
- }
240
- return { report: null, fallbackAdopted: false, staleRejected };
241
- }
242
- /**
243
- * Back-compat wrapper around `selectReportForSession` for callers that
244
- * only need the report (the gate read and expiry paths).
245
- */
246
- export function findLatestReportForSession(reports, sessionId, opts = {}) {
247
- return selectReportForSession(reports, sessionId, opts).report;
248
- }
249
- /**
250
- * Phase 6 #6 — substring-pollution defence shared by every PreToolUse
251
- * blocker (Claude Code + Codex). Drops policy_decision rows so a
252
- * `understanding-approved:<sess>` substring inside an audit row's
253
- * JSON-encoded `reason` cannot accidentally satisfy the gate.
254
- */
255
- export function isPolicyDecisionRow(e) {
256
- if (e.type === POLICY_DECISION_TYPE)
257
- return true;
258
- if (typeof e.content === "string" && e.content.startsWith(`${POLICY_DECISION_TYPE}:`)) {
259
- return true;
260
- }
261
- return false;
262
- }
263
- /**
264
- * Match a ledger fetch against the per-session approval tag. Kept for
265
- * the audit / forensics path only: a ledger entry tagged
266
- * `understanding-approved:<sid>` is no longer a sufficient signal to
267
- * unblock the gate (agent-tasks/88ca4bb3: the agent has the same MCP
268
- * surface and could self-write the row). Use `checkApprovalMarker`
269
- * for the gate decision; this helper now serves `harness audit` /
270
- * `harness explain --trace` style read paths that surface the
271
- * historic ledger trail without granting approval power.
272
- */
273
- export function matchLedgerEntries(entries, sessionId) {
274
- const wanted = approvedLedgerTagFor(sessionId);
275
- let scanned = 0;
276
- for (const e of entries) {
277
- if (isPolicyDecisionRow(e))
278
- continue;
279
- scanned += 1;
280
- if (typeof e.content === "string" && e.content.includes(wanted)) {
281
- return {
282
- matched: true,
283
- detail: `audit: ledger tag ${wanted} present at ${e.createdAt} (no longer satisfies the gate; see harness.generated/${APPROVAL_MARKER_DIRNAME}/${sessionId})`,
284
- };
285
- }
286
- }
287
- return {
288
- matched: false,
289
- detail: `no ledger entry matched ${wanted} (scanned ${scanned} non-policy_decision row(s))`,
290
- };
291
- }
292
- /** Filesystem path of the per-session approval marker. */
293
- export function approvalMarkerPathFor(generatedDir, sessionId) {
294
- rejectMalformedSessionId(sessionId);
295
- return path.join(generatedDir, APPROVAL_MARKER_DIRNAME, sessionId);
296
- }
297
- /**
298
- * Operator-side: write the marker file the gate consults. Atomic so a
299
- * crash mid-write cannot leave a half-empty file the gate would accept
300
- * as approved. Caller is `harness approve understanding`, which the
301
- * operator runs from their un-hooked shell; if the agent could call
302
- * this path the gate's value would collapse, so it lives behind the
303
- * approve CLI rather than as a generally importable verb.
304
- *
305
- * The marker is HMAC-signed (harness/f9485cc7) over (sessionId,
306
- * approvedAt, approvedBy, reportContentHash) using an operator-side key
307
- * lazily generated at `<generatedDir>/.approval-signing.key` — see
308
- * `src/runtime/approval-signing.ts` for the key-management contract and
309
- * the honest trust model. Mode 0600 on the marker file itself, best-
310
- * effort (matches the signing key's own permission convention); this is
311
- * defense-in-depth alongside the signature, not a substitute for it.
312
- */
313
- export function writeApprovalMarker(generatedDir, sessionId, marker) {
314
- const filePath = approvalMarkerPathFor(generatedDir, sessionId);
315
- const signed = signMarker(generatedDir, sessionId, marker);
316
- atomicWriteFile(filePath, `${JSON.stringify(signed, null, 2)}\n`, { mode: 0o600 });
317
- return filePath;
318
- }
319
- /**
320
- * Gate-side: is the per-session marker file present, readable, AND
321
- * validly signed? (harness/f9485cc7 — this replaced the earlier
322
- * "existence is enough" contract: a marker's mere presence used to
323
- * satisfy the gate even with a malformed or corrupted body, on the
324
- * theory that Edit/Write/Bash being gated meant only the operator could
325
- * ever create the file. That theory holds only as long as no OTHER
326
- * write primitive reaches `harness.generated/` — exactly the invariant
327
- * this signing scheme no longer has to trust blindly.) A marker with no
328
- * `signature`, a wrong `alg`, or a signature that fails to verify is
329
- * REJECTED — see `MarkerCheck.forged` — with the SAME `matched: false`
330
- * outcome as no marker at all, but a distinct, forgery-specific detail
331
- * string so callers can tell the two apart for audit.
332
- *
333
- * `opts.maxAgeMs` (agent-tasks/d8ee60ca): when set, a validly-signed
334
- * marker whose `approvedAt` is older than the cutoff returns
335
- * `matched:false` with an "expired" detail so the agent gets the same
336
- * "no approval" UX as a never-approved session and must re-approve.
337
- * Expiry is only evaluated AFTER signature verification succeeds — an
338
- * unsigned/forged marker is rejected outright, never "expired".
339
- */
340
- export function checkApprovalMarker(generatedDir, sessionId, opts = {}) {
341
- // Construct the path defensively: a malformed sessionId must fail CLOSED
342
- // here (no valid marker, so the gate blocks and demands approval), not
343
- // throw out of the gate hook, which the top-level handler turns into a
344
- // non-blocking exit that would let the gated tool proceed.
345
- let filePath;
346
- try {
347
- filePath = approvalMarkerPathFor(generatedDir, sessionId);
348
- }
349
- catch (err) {
350
- return {
351
- matched: false,
352
- detail: `invalid sessionId for approval marker: ${err instanceof Error ? err.message : String(err)}`,
353
- marker: null,
354
- expired: false,
355
- forged: false,
356
- };
357
- }
358
- // Shared symlink-rejecting read (src/io/read-regular-file.ts): the lstat
359
- // reject is defense-in-depth against a planted symlink
360
- // (agent-tasks/d39f160e); each failure mode keeps its distinct detail.
361
- const read = readRegularFileRejectingSymlink(filePath);
362
- if (read.kind === "missing") {
363
- return {
364
- matched: false,
365
- detail: `no approval marker at ${filePath}`,
366
- marker: null,
367
- expired: false,
368
- forged: false,
369
- };
370
- }
371
- if (read.kind === "symlink") {
372
- return {
373
- matched: false,
374
- detail: `approval marker is a symlink, refusing for safety: ${filePath}`,
375
- marker: null,
376
- expired: false,
377
- forged: false,
378
- };
379
- }
380
- if (read.kind === "not-regular") {
381
- return {
382
- matched: false,
383
- detail: `approval marker path is not a regular file: ${filePath}`,
384
- marker: null,
385
- expired: false,
386
- forged: false,
387
- };
388
- }
389
- if (read.kind === "unreadable") {
390
- // A genuine I/O error reading an existing regular file (permissions
391
- // changed mid-flight, etc.) — not evidence of tampering, but its
392
- // signature cannot be proven either, so fail closed. Kept distinct
393
- // from `forged` (no claim of an active forgery attempt).
394
- return {
395
- matched: false,
396
- detail: `approval marker at ${filePath} exists but could not be read (I/O error); treating as unapproved since its signature cannot be verified`,
397
- marker: null,
398
- expired: false,
399
- forged: false,
400
- };
401
- }
402
- // read.kind === "ok"
403
- const parsed = safeJsonParse(read.content);
404
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
405
- return {
406
- matched: false,
407
- detail: `forged/unsigned marker rejected: marker body at ${filePath} is not a JSON object`,
408
- marker: null,
409
- expired: false,
410
- forged: true,
411
- };
412
- }
413
- const obj = parsed;
414
- const verification = verifyMarkerSignature(generatedDir, sessionId, obj);
415
- if (!verification.ok) {
416
- if (verification.kind === "key-unavailable") {
417
- // Fail-closed I/O problem (permission error / disk issue reading or
418
- // creating the signing key), NOT evidence of an active forgery
419
- // attempt — a marker written by the operator with a perfectly valid
420
- // signature would also fail this exact check if the key file became
421
- // unreadable. Kept distinct from `forged` (review LOW 1,
422
- // harness/f9485cc7) so a broken key file doesn't read as an attack
423
- // in audit output, mirroring the `read.kind === "unreadable"` branch
424
- // above.
425
- return {
426
- matched: false,
427
- detail: `approval marker at ${filePath} could not be verified: ${verification.reason}; treating as unapproved`,
428
- marker: null,
429
- expired: false,
430
- forged: false,
431
- };
432
- }
433
- return {
434
- matched: false,
435
- detail: `forged/unsigned marker rejected: ${verification.reason} (${filePath})`,
436
- marker: null,
437
- expired: false,
438
- forged: true,
439
- };
440
- }
441
- const approvedAt = obj["approvedAt"];
442
- const approvedBy = obj["approvedBy"];
443
- const reportContentHash = typeof obj["reportContentHash"] === "string" ? obj["reportContentHash"] : null;
444
- const marker = { approvedAt, approvedBy, reportContentHash };
445
- if (opts.maxAgeMs !== undefined) {
446
- const approvedAtMs = Date.parse(approvedAt);
447
- if (Number.isFinite(approvedAtMs)) {
448
- const nowMs = (opts.now ?? new Date()).getTime();
449
- const ageMs = nowMs - approvedAtMs;
450
- if (ageMs > opts.maxAgeMs) {
451
- const ageMin = Math.round(ageMs / 60_000);
452
- const maxMin = Math.round(opts.maxAgeMs / 60_000);
453
- return {
454
- matched: false,
455
- detail: `approval marker ${path.basename(filePath)} expired: age ${ageMin}m > max ${maxMin}m (approved at ${approvedAt})`,
456
- marker,
457
- expired: true,
458
- forged: false,
459
- };
460
- }
461
- }
462
- }
463
- return {
464
- matched: true,
465
- detail: `approved via marker ${path.basename(filePath)}: approved at ${approvedAt} by ${approvedBy}, signature verified`,
466
- marker,
467
- expired: false,
468
- forged: false,
469
- };
470
- }
471
- const DEFAULT_LIFECYCLE = {
472
- expireOnToolMatch: [],
473
- expireOnBashMatch: [],
474
- legacyMode: false,
475
- };
476
- /**
477
- * Parse the optional `approval_lifecycle` block from a pack config.
478
- * Best-effort: malformed values fall back to the default (no expiry,
479
- * legacyMode=false) and write a one-line warning to the supplied
480
- * stderr. The PreToolUse / PostToolUse hooks must keep working even
481
- * when the operator typed a typo in the YAML.
482
- */
483
- export function parseApprovalLifecycle(raw, stderr) {
484
- if (raw === undefined || raw === null)
485
- return DEFAULT_LIFECYCLE;
486
- if (typeof raw !== "object" || Array.isArray(raw)) {
487
- stderr?.write(`harness pack hook: config.approval_lifecycle ignored (expected object, got ${typeof raw})\n`);
488
- return DEFAULT_LIFECYCLE;
489
- }
490
- const obj = raw;
491
- if (obj["mode"] === "session") {
492
- return { expireOnToolMatch: [], expireOnBashMatch: [], legacyMode: true };
493
- }
494
- const expireOnToolMatch = [];
495
- const list = obj["expire_on_tool_match"];
496
- if (Array.isArray(list)) {
497
- for (const v of list) {
498
- if (typeof v === "string" && v.length > 0)
499
- expireOnToolMatch.push(v);
500
- }
501
- }
502
- else if (list !== undefined) {
503
- stderr?.write(`harness pack hook: config.approval_lifecycle.expire_on_tool_match ignored (expected string[], got ${typeof list})\n`);
504
- }
505
- const expireOnBashMatch = [];
506
- const bashList = obj["expire_on_bash_match"];
507
- if (Array.isArray(bashList)) {
508
- for (const v of bashList) {
509
- if (typeof v !== "string" || v.length === 0)
510
- continue;
511
- try {
512
- expireOnBashMatch.push(new RegExp(v));
513
- }
514
- catch (err) {
515
- stderr?.write(`harness pack hook: config.approval_lifecycle.expire_on_bash_match entry ignored ("${v}"): ${err.message}\n`);
516
- }
517
- }
518
- }
519
- else if (bashList !== undefined) {
520
- stderr?.write(`harness pack hook: config.approval_lifecycle.expire_on_bash_match ignored (expected string[], got ${typeof bashList})\n`);
521
- }
522
- let maxAgeMs;
523
- const maxAgeRaw = obj["max_age"];
524
- if (typeof maxAgeRaw === "string" && maxAgeRaw.length > 0) {
525
- try {
526
- maxAgeMs = parseDurationSeconds(maxAgeRaw) * 1_000;
527
- }
528
- catch (err) {
529
- const msg = err instanceof InvalidDurationError ? err.message : String(err);
530
- stderr?.write(`harness pack hook: config.approval_lifecycle.max_age ignored: ${msg}\n`);
531
- }
532
- }
533
- else if (maxAgeRaw !== undefined) {
534
- stderr?.write(`harness pack hook: config.approval_lifecycle.max_age ignored (expected duration string like "4h", got ${typeof maxAgeRaw})\n`);
535
- }
536
- return {
537
- expireOnToolMatch,
538
- expireOnBashMatch,
539
- ...(maxAgeMs !== undefined && { maxAgeMs }),
540
- legacyMode: false,
541
- };
542
- }
543
- /**
544
- * Flip the latest matching persisted report's approvalStatus to
545
- * `expired` so it no longer satisfies the gate's persisted-report
546
- * fallback (harness/1ee26e77 follow-up: post-tool-use expiry was
547
- * marker-only; the persisted report at .understanding-gate/reports/
548
- * silently kept satisfying the gate even after task_finish deleted
549
- * the marker).
550
- *
551
- * Atomic rewrite. Preserves the rest of the report body so the audit
552
- * trail (the operator's actual Understanding text + previous approval
553
- * timestamps) stays intact; only the status fields change.
554
- *
555
- * Returns `{ ok: true, filePath, previousStatus }` on success,
556
- * `{ ok: false, reason }` when no matching report exists or rewrite
557
- * failed. Non-throwing: caller (post-tool-use hook) uses this as a
558
- * best-effort cleanup so a missing report dir or unrelated I/O issue
559
- * does not escalate into a session-breaking hook failure.
560
- */
561
- export function expirePersistedReport(reportsDir, sessionId, now = new Date()) {
562
- const reports = listPersistedReports(reportsDir);
563
- if (reports.length === 0) {
564
- return { ok: false, reason: `no reports under ${reportsDir}` };
565
- }
566
- const latest = findLatestReportForSession(reports, sessionId);
567
- if (!latest) {
568
- return {
569
- ok: false,
570
- reason: `no report matched session_id=${sessionId} (${reports.length} report(s) for other sessions)`,
571
- };
572
- }
573
- if (latest.approvalStatus !== "approved") {
574
- return {
575
- ok: false,
576
- reason: `latest report ${path.basename(latest.filePath)} already has approvalStatus=${latest.approvalStatus ?? "<missing>"}, nothing to expire`,
577
- };
578
- }
579
- let raw;
580
- try {
581
- raw = fs.readFileSync(latest.filePath, "utf8");
582
- }
583
- catch (err) {
584
- return { ok: false, reason: `failed to read ${latest.filePath}: ${err.message}` };
585
- }
586
- let parsed;
587
- try {
588
- parsed = JSON.parse(raw);
589
- }
590
- catch (err) {
591
- return { ok: false, reason: `failed to parse ${latest.filePath}: ${err.message}` };
592
- }
593
- const previousStatus = typeof parsed["approvalStatus"] === "string" ? parsed["approvalStatus"] : null;
594
- parsed["approvalStatus"] = "expired";
595
- parsed["expiredAt"] = now.toISOString();
596
- try {
597
- atomicWriteFile(latest.filePath, `${JSON.stringify(parsed, null, 2)}\n`);
598
- }
599
- catch (err) {
600
- return {
601
- ok: false,
602
- reason: `failed to rewrite ${latest.filePath}: ${err.message}`,
603
- };
604
- }
605
- return { ok: true, filePath: latest.filePath, previousStatus };
606
- }
607
- /** Clear the per-session marker (used by `harness approve --revoke` and tests). */
608
- export function clearApprovalMarker(generatedDir, sessionId) {
609
- try {
610
- fs.rmSync(approvalMarkerPathFor(generatedDir, sessionId));
611
- }
612
- catch {
613
- /* already gone */
614
- }
615
- }
616
- // Task-scoped approval markers (harness/1ee26e77). When the operator
617
- // passes `--task <id>` to `harness approve understanding`, a second
618
- // marker file is written next to the session marker, keyed by the
619
- // agent-tasks task id and prefixed `task-` so a directory scan can
620
- // distinguish them from session-keyed markers (UUIDs do not start with
621
- // `task-`). Either marker satisfies the gate; the session marker
622
- // remains for back-compat with operators who don't pass --task and for
623
- // non-agent-tasks workflows.
624
- //
625
- // Why task-scope: a session can span many distinct tasks; the operator's
626
- // Understanding Report for task A doesn't transfer trust to task B
627
- // (different surface, different acceptance criteria). Without per-task
628
- // markers a multi-task session re-uses the first task's approval for
629
- // every subsequent task. The expire_on_tool_match PostToolUse hook
630
- // already deletes the session marker on `task_finish`, but if delivery
631
- // is unreliable (e.g. PostToolUse skipped for an MCP tool) the marker
632
- // silently persists. A task-keyed marker side-steps that delivery
633
- // concern because the next task's id is different even if the previous
634
- // marker file outlives its scope.
635
- export const APPROVAL_MARKER_TASK_PREFIX = "task-";
636
- /**
637
- * Reject taskIds that would escape the approvals/ namespace via path
638
- * traversal or directory separators. The operator's --task flag value
639
- * lands here verbatim; an accidental shell-expanded `..` or `/` would
640
- * otherwise write to a sibling directory. This is defensive — the
641
- * caller is the operator's own shell — but pins the trust boundary.
642
- */
643
- function rejectMalformedTaskId(taskId) {
644
- if (taskId.length === 0) {
645
- throw new Error("taskId is empty");
646
- }
647
- if (taskId.includes("/") || taskId.includes("\\") || taskId.includes("..")) {
648
- throw new Error(`taskId contains path-separator or traversal characters: ${JSON.stringify(taskId)}`);
649
- }
650
- }
651
- /** Filesystem path of a per-task approval marker. */
652
- export function taskApprovalMarkerPathFor(generatedDir, taskId) {
653
- rejectMalformedTaskId(taskId);
654
- return path.join(generatedDir, APPROVAL_MARKER_DIRNAME, `${APPROVAL_MARKER_TASK_PREFIX}${taskId}`);
655
- }
656
- /**
657
- * Operator-side: write a task-scoped marker file. Atomic. Caller is
658
- * `harness approve understanding --task <id>`. The session marker is
659
- * written separately by the same caller for back-compat.
660
- *
661
- * Signed the same way as `writeApprovalMarker` (harness/f9485cc7), using
662
- * `task-<id>` (the exact string `checkActiveClaimApprovalMarker` looks
663
- * up) as the signed markerId — so a validly-signed task marker cannot be
664
- * copied onto a different task id (or the session marker's id) and still
665
- * verify.
666
- */
667
- export function writeTaskApprovalMarker(generatedDir, taskId, marker) {
668
- const filePath = taskApprovalMarkerPathFor(generatedDir, taskId);
669
- const markerId = `${APPROVAL_MARKER_TASK_PREFIX}${taskId}`;
670
- const signed = signMarker(generatedDir, markerId, marker);
671
- atomicWriteFile(filePath, `${JSON.stringify(signed, null, 2)}\n`, { mode: 0o600 });
672
- return filePath;
673
- }
674
- /**
675
- * Gate-side: resolve the active agent-tasks claim (via `active-claim`)
676
- * and check ONLY that task's approval marker. When no active claim is
677
- * recorded, this returns `matched:false` so the caller falls through
678
- * to the session marker — preserving the legacy contract for solo /
679
- * non-agent-tasks workflows that never call `task_start`.
680
- *
681
- * Replaces the v1 "any task marker satisfies the gate" behaviour
682
- * (PR #198): a stale approval from a different, already-completed task
683
- * was silently authorising every Edit/Write/Bash in the next session
684
- * because the scan returned the first existing marker regardless of
685
- * which task the agent had actually claimed.
686
- *
687
- * Same safety filters as `checkApprovalMarker` (signature verification,
688
- * symlink rejection, optional freshness via `maxAgeMs`); the only
689
- * difference is the filename suffix derived from `active-claim`.
690
- */
691
- export function checkActiveClaimApprovalMarker(generatedDir, opts = {}) {
692
- const claim = readActiveClaim(generatedDir);
693
- if (claim === null) {
694
- return {
695
- matched: false,
696
- detail: `no active-claim recorded; task-scoped check skipped`,
697
- marker: null,
698
- expired: false,
699
- forged: false,
700
- };
701
- }
702
- const markerName = `${APPROVAL_MARKER_TASK_PREFIX}${claim}`;
703
- const check = checkApprovalMarker(generatedDir, markerName, opts);
704
- if (check.matched) {
705
- return {
706
- matched: true,
707
- detail: `task-scoped marker for active-claim ${claim}: ${check.detail}`,
708
- marker: check.marker,
709
- expired: false,
710
- forged: false,
711
- };
712
- }
713
- return {
714
- matched: false,
715
- detail: `active-claim ${claim} has no fresh task marker (${check.detail})`,
716
- marker: null,
717
- expired: check.expired,
718
- forged: check.forged,
719
- };
720
- }
721
- /**
722
- * Shared marker resolution for the Claude and Codex understanding-gate
723
- * PreToolUse hooks (task e7c2ec3c): parse `approval_lifecycle` from the
724
- * pack config, consult the task-scoped (active-claim) marker first and
725
- * the session-scoped marker second, both under the same TTL. One code
726
- * path on purpose — the Codex hook previously called the bare session
727
- * check, so `max_age` and task-scoping silently applied only to Claude
728
- * sessions.
729
- */
730
- export function checkOperatorApprovalMarkers(generatedDir, sessionId, packConfig, stderr) {
731
- const lifecycleRaw = packConfig !== null && typeof packConfig === "object" && !Array.isArray(packConfig)
732
- ? packConfig["approval_lifecycle"]
733
- : undefined;
734
- const lifecycle = parseApprovalLifecycle(lifecycleRaw, stderr);
735
- const ageOpts = lifecycle.maxAgeMs !== undefined ? { maxAgeMs: lifecycle.maxAgeMs } : {};
736
- const taskMarker = checkActiveClaimApprovalMarker(generatedDir, ageOpts);
737
- if (taskMarker.matched) {
738
- return {
739
- matched: true,
740
- source: "task",
741
- detail: taskMarker.detail,
742
- taskCheckDetail: taskMarker.detail,
743
- expired: false,
744
- forged: false,
745
- };
746
- }
747
- const sessionMarker = checkApprovalMarker(generatedDir, sessionId, ageOpts);
748
- if (sessionMarker.matched) {
749
- return {
750
- matched: true,
751
- source: "session",
752
- detail: sessionMarker.detail,
753
- taskCheckDetail: taskMarker.detail,
754
- expired: false,
755
- forged: false,
756
- };
757
- }
758
- return {
759
- matched: false,
760
- source: null,
761
- detail: sessionMarker.detail,
762
- taskCheckDetail: taskMarker.detail,
763
- // `expired` is computed ONLY on this non-matched path, preserving the
764
- // "false when matched is true" invariant (task 6e888423 review):
765
- // e.g. a FRESH session marker (matched:true, returned above) must not
766
- // read expired:true just because a STALE sibling task marker also
767
- // exists — that sibling is irrelevant once the session marker itself
768
- // satisfied the gate. Either marker aged out counts here: a
769
- // task-scoped approval that expired (even though the session-scoped
770
- // check may simply have never existed) is just as legitimate a "this
771
- // had approval, it lapsed" signal as a session-scoped expiry.
772
- expired: taskMarker.expired || sessionMarker.expired,
773
- // Surfaced so a forgery attempt against EITHER marker is visible to
774
- // the caller even though the overall result is (correctly) unmatched
775
- // — mirrors the `expired` OR-merge above.
776
- forged: taskMarker.forged || sessionMarker.forged,
777
- };
778
- }
779
- /** Clear a specific task-scoped marker. Used by the post-tool-use hook. */
780
- export function clearTaskApprovalMarker(generatedDir, taskId) {
781
- try {
782
- fs.rmSync(taskApprovalMarkerPathFor(generatedDir, taskId));
783
- }
784
- catch {
785
- /* already gone */
786
- }
787
- }
788
- // ---------------------------------------------------------------------
789
- // PostToolUse boundary matching + expiry (task a1348c89, Codex parity).
790
- // Shared between `harness pack hook post-tool-use` (Claude) and
791
- // `harness pack hook codex-post-tool-use` (Codex): both clear the SAME
792
- // marker/task-marker/persisted-report state on the SAME configured
793
- // task-boundary tools. Before this extraction the matching logic lived
794
- // only inline in the Claude hook file; a Codex sibling copying it by
795
- // hand would have re-implemented (and eventually drifted from) it —
796
- // the exact drift class task e7c2ec3c fixed on the PreToolUse side via
797
- // `checkOperatorApprovalMarkers` above. One implementation, two thin
798
- // runtime-specific callers.
799
- // ---------------------------------------------------------------------
800
- /**
801
- * Tool names treated as "the Bash tool" for `expire_on_bash_match`
802
- * command-regex matching. Claude Code has exactly one Bash tool name;
803
- * a runtime with shell-tool aliases (e.g. Codex's `shell` /
804
- * `exec_command` / `functions.exec_command`) passes its own set.
805
- */
806
- export const DEFAULT_BASH_TOOL_NAMES = new Set(["Bash"]);
807
- /** Canonical agent-tasks v1 tool name whose `tool_input.status` gates
808
- * marker expiry (see `matchPostToolUseBoundary` below). Not imported
809
- * from `hook-track-active-claim.ts`'s own `TOOL_NAME_TASKS_TRANSITION`
810
- * constant: `policy-packs/` may not import from `cli/` (layering rule,
811
- * `.dependency-cruiser.cjs`). */
812
- const TASKS_TRANSITION_TOOL_NAME = "mcp__agent-tasks__tasks_transition";
813
- /**
814
- * Tool-name membership test against `expire_on_tool_match`. No GLOB
815
- * wildcard expansion by design (agent-tasks/d8ee60ca): operators write
816
- * the exact tool name they mean.
817
- *
818
- * It IS alias-aware, though: a tool name is compared against `patterns`
819
- * after expanding it through `expandToolNameAliases` (shell-tool
820
- * aliases `Bash`/`shell`/`exec_command`/`functions.exec_command`, and
821
- * MCP tool-name variants — server hyphen/underscore swap, the
822
- * `mcp__server__.tool` dotted form). This mirrors the normalization
823
- * `harness policy intercept`'s `policyMatchesEvent` already applies to
824
- * the incoming `event.tool_name` (`src/runtime/intercept.ts`, commit
825
- * 9aacbcd "Fix Codex hook tool matching") — that fix exists precisely
826
- * because Codex can emit an MCP tool name in one of these variant
827
- * forms for the identical tool. Before this, a Codex session sending
828
- * `mcp__agent-tasks__.task_finish` (dotted) or an underscore-server
829
- * variant would silently never expire the marker: the Codex generator
830
- * alias-expands the EMITTED TOML `matcher` (`expandCodexHookMatchPattern`
831
- * in `generate-codex-config.ts`) so Codex's own dispatcher still
832
- * invokes the hook command, but this function's comparison against the
833
- * canonical `expire_on_tool_match` config list would reject the
834
- * variant `tool_name` once inside the hook body — the boundary would
835
- * silently never fire even though the hook ran (review finding on task
836
- * a1348c89).
837
- */
838
- export function toolNameMatchesAny(toolName, patterns) {
839
- const patternSet = new Set(patterns);
840
- return expandToolNameAliases(toolName).some((alias) => patternSet.has(alias));
841
- }
842
- /** First `expire_on_bash_match` regex the command satisfies, or
843
- * undefined. Patterns are pre-compiled by `parseApprovalLifecycle`
844
- * (invalid ones already dropped with a warning). Empty command
845
- * short-circuits to undefined. */
846
- export function bashCommandMatchesAny(command, patterns) {
847
- if (command === "")
848
- return undefined;
849
- for (const re of patterns) {
850
- if (re.test(command))
851
- return re;
852
- }
853
- return undefined;
854
- }
855
- function toolInputRecord(toolInput) {
856
- if (typeof toolInput !== "object" || toolInput === null || Array.isArray(toolInput)) {
857
- return null;
858
- }
859
- return toolInput;
860
- }
861
- /** Pull the Bash command out of a tool_input payload, `""` when absent
862
- * or malformed. */
863
- export function extractBashCommandFromToolInput(toolInput) {
864
- const command = toolInputRecord(toolInput)?.["command"];
865
- return typeof command === "string" ? command : "";
866
- }
867
- /** Pull `taskId` out of an MCP tool_input payload (`""` when absent /
868
- * malformed). Task-boundary agent-tasks verbs carry this as a
869
- * top-level string field. */
870
- export function extractTaskIdFromToolInput(toolInput) {
871
- const tid = toolInputRecord(toolInput)?.["taskId"];
872
- return typeof tid === "string" ? tid : "";
873
- }
874
- /** Pull the legacy v1 `tasks_transition` `status` field out of a
875
- * tool_input payload (`""` when absent / malformed). Only `"done"`
876
- * releases the work claim; the caller treats any other value as
877
- * keep-claim. */
878
- export function extractTasksTransitionStatusFromToolInput(toolInput) {
879
- const s = toolInputRecord(toolInput)?.["status"];
880
- return typeof s === "string" ? s : "";
881
- }
882
- /**
883
- * Decide whether `toolName` (+ its `tool_input`) crosses one of the
884
- * configured `approval_lifecycle` boundaries. Pure — no filesystem
885
- * access. Callers branch on `.matched` before touching marker/report
886
- * state (see `applyPostToolUseExpiry`).
887
- */
888
- export function matchPostToolUseBoundary(toolName, toolInput, lifecycle, bashToolNames = DEFAULT_BASH_TOOL_NAMES) {
889
- const rawToolNameMatched = toolNameMatchesAny(toolName, lifecycle.expireOnToolMatch);
890
- // Legacy v1 `tasks_transition`: only `status=done` releases the work
891
- // claim (per task_finish docs: "The work claim is cleared when going
892
- // to done and kept when going to review"). open / in_progress /
893
- // review / missing status keep the marker. Detected via the same
894
- // alias-aware `toolNameMatchesAny` as the general match above (not a
895
- // raw `===`): a Codex dotted/server-variant `tasks_transition`
896
- // tool_name must still get the status filter applied, otherwise it
897
- // would fall through to the unconditional `true` branch below and
898
- // clear the marker on ANY status — a worse bug than a missed match
899
- // (review finding on task a1348c89).
900
- const tasksTransitionStatusOk = toolNameMatchesAny(toolName, [
901
- TASKS_TRANSITION_TOOL_NAME,
902
- ])
903
- ? extractTasksTransitionStatusFromToolInput(toolInput) === "done"
904
- : true;
905
- const toolNameMatched = rawToolNameMatched && tasksTransitionStatusOk;
906
- // Bash check only runs when the event is actually a Bash(-alias) call;
907
- // an MCP tool whose name happens to match a regex is not a Bash
908
- // boundary.
909
- const bashRegex = bashToolNames.has(toolName)
910
- ? bashCommandMatchesAny(extractBashCommandFromToolInput(toolInput), lifecycle.expireOnBashMatch)
911
- : undefined;
912
- return {
913
- matched: toolNameMatched || bashRegex !== undefined,
914
- toolNameMatched,
915
- rawToolNameMatched,
916
- bashRegex,
917
- };
918
- }
919
- /**
920
- * Side-effecting: clear the session marker, the task-scoped marker
921
- * (when `toolNameMatched` and `tool_input.taskId` names an existing
922
- * marker), and expire the persisted report. Call ONLY after
923
- * `matchPostToolUseBoundary(...).matched` is true — this function does
924
- * not re-check the match itself. Mirrors the Claude hook's original
925
- * side effects exactly so both runtimes share one clearing
926
- * implementation (task a1348c89).
927
- */
928
- export function applyPostToolUseExpiry(generatedDir, sessionId, toolInput, toolNameMatched, reportsDir, now) {
929
- const markerPath = approvalMarkerPathFor(generatedDir, sessionId);
930
- const wasMarkerPresent = fs.existsSync(markerPath);
931
- clearApprovalMarker(generatedDir, sessionId);
932
- // Task-scoped marker cleanup (harness/1ee26e77). Only when the
933
- // matched tool is an MCP task-transition verb whose tool_input.taskId
934
- // names a specific task; Bash regex boundaries don't carry a taskId
935
- // by design.
936
- let taskMarkerCleared = false;
937
- let clearedTaskId = "";
938
- if (toolNameMatched) {
939
- const taskId = extractTaskIdFromToolInput(toolInput);
940
- if (taskId !== "") {
941
- const taskMarkerPath = taskApprovalMarkerPathFor(generatedDir, taskId);
942
- if (fs.existsSync(taskMarkerPath)) {
943
- clearTaskApprovalMarker(generatedDir, taskId);
944
- taskMarkerCleared = true;
945
- clearedTaskId = taskId;
946
- }
947
- }
948
- }
949
- // Persisted-report expiry (harness/1ee26e77 follow-up). Closes the
950
- // silent bypass that existed since PR #172: marker-deletion alone did
951
- // not invalidate the persisted-report fallback. Best-effort.
952
- const reportExpiry = expirePersistedReport(reportsDir, sessionId, now);
953
- return {
954
- wasMarkerPresent,
955
- taskMarkerCleared,
956
- clearedTaskId,
957
- persistedReportExpired: reportExpiry.ok,
958
- reportExpiry,
959
- };
960
- }
961
- /**
962
- * Compose the stderr diagnostic line for a matched-and-applied PostToolUse
963
- * expiry. Shared by `harness pack hook post-tool-use` (Claude) and
964
- * `harness pack hook codex-post-tool-use` (Codex) — before this extraction
965
- * the two hooks each built this line by hand from the same four pieces
966
- * (`matchSource`/`taskNote`/`reportNote`/the present-vs-absent branch),
967
- * which `check:duplication` flagged as new clones the moment the Codex
968
- * sibling landed (task a1348c89). `hookLabel` is the caller's own
969
- * `harness pack hook ...` command string so the message still names the
970
- * right verb.
971
- */
972
- export function describePostToolUseExpiry(hookLabel, sessionId, toolName, bashRegex, expiry) {
973
- const matchSource = bashRegex !== undefined
974
- ? `bash regex /${bashRegex.source}/`
975
- : `tool name`;
976
- const taskNote = expiry.taskMarkerCleared
977
- ? `; also cleared task marker for task ${expiry.clearedTaskId}`
978
- : "";
979
- const reportNote = expiry.reportExpiry.ok
980
- ? `; expired persisted report ${expiry.reportExpiry.filePath}`
981
- : `; persisted-report expiry skipped (${expiry.reportExpiry.reason})`;
982
- return expiry.wasMarkerPresent
983
- ? `${hookLabel}: expired approval marker for session ${sessionId} after ${toolName} (${matchSource})${taskNote}${reportNote}`
984
- : `${hookLabel}: ${toolName} matched ${matchSource} but no marker present for session ${sessionId}${taskNote}${reportNote}`;
985
- }
986
- // Active-claim tracking (harness/494fd1e5). When the agent calls
987
- // `mcp__agent-tasks__task_start`, a PostToolUse hook writes the claimed
988
- // task id to a stable file. `harness approve understanding` reads it
989
- // when --task is absent and auto-supplies it as the task-scoped marker
990
- // target. This closes the v1 ergonomics gap from PR #184 where the
991
- // operator had to type the taskId by hand.
992
- //
993
- // File contract: a single line containing just the taskId. No JSON,
994
- // no metadata. Operators can `cat` it to debug. The post-tool-use
995
- // hook on task_finish / task_abandon removes the file.
996
- export const ACTIVE_CLAIM_FILENAME = "active-claim";
997
- export function activeClaimPathFor(generatedDir) {
998
- return path.join(generatedDir, ACTIVE_CLAIM_FILENAME);
999
- }
1000
- function rejectMalformedClaimId(taskId) {
1001
- if (taskId.length === 0) {
1002
- throw new Error("taskId is empty");
1003
- }
1004
- if (taskId.includes("\n") ||
1005
- taskId.includes("\r") ||
1006
- taskId.includes("/") ||
1007
- taskId.includes("\\") ||
1008
- taskId.includes("..")) {
1009
- throw new Error(`taskId contains forbidden characters (newline / path-separator / traversal): ${JSON.stringify(taskId)}`);
1010
- }
1011
- }
1012
- /**
1013
- * Hook-side: write the active claim file. Atomic. Called from the
1014
- * track-active-claim PostToolUse hook on `task_start`. The body is
1015
- * just the taskId (no JSON) so an operator running
1016
- * `cat ~/.claude/harness.generated/active-claim` sees the id directly.
1017
- */
1018
- export function writeActiveClaim(generatedDir, taskId) {
1019
- rejectMalformedClaimId(taskId);
1020
- const filePath = activeClaimPathFor(generatedDir);
1021
- atomicWriteFile(filePath, `${taskId}\n`);
1022
- return filePath;
1023
- }
1024
- /**
1025
- * Operator-side: read the active claim file. Returns the taskId or
1026
- * null when the file is absent / unreadable / empty. `harness approve
1027
- * understanding` calls this when --task is absent, then passes the
1028
- * resolved id to writeTaskApprovalMarker.
1029
- *
1030
- * Defense-in-depth: if the on-disk content fails the same
1031
- * path-traversal / newline check that gates writes, the read returns
1032
- * null instead of surfacing a poisoned id. The write side guards
1033
- * against a malformed taskId reaching the file in the first place,
1034
- * but a stale file authored before this guard (or hand-edited)
1035
- * shouldn't escalate into a forged task-marker write downstream.
1036
- */
1037
- export function readActiveClaim(generatedDir) {
1038
- const filePath = activeClaimPathFor(generatedDir);
1039
- let raw;
1040
- try {
1041
- raw = fs.readFileSync(filePath, "utf8");
1042
- }
1043
- catch {
1044
- return null;
1045
- }
1046
- const trimmed = raw.trim();
1047
- if (trimmed.length === 0)
1048
- return null;
1049
- try {
1050
- rejectMalformedClaimId(trimmed);
1051
- }
1052
- catch {
1053
- return null;
1054
- }
1055
- return trimmed;
1056
- }
1057
- /** Hook-side: remove the active claim file. Idempotent. */
1058
- export function clearActiveClaim(generatedDir) {
1059
- try {
1060
- fs.rmSync(activeClaimPathFor(generatedDir));
1061
- }
1062
- catch {
1063
- /* already gone */
1064
- }
1065
- }
1066
- export function checkPersistedReport(reportsDir, sessionId) {
1067
- const reports = listPersistedReports(reportsDir);
1068
- if (reports.length === 0) {
1069
- return {
1070
- approved: false,
1071
- detail: `no reports found at ${reportsDir}`,
1072
- report: null,
1073
- };
1074
- }
1075
- const latest = findLatestReportForSession(reports, sessionId);
1076
- if (!latest) {
1077
- return {
1078
- approved: false,
1079
- detail: `no report matched session_id=${sessionId} (${reports.length} report(s) for other sessions)`,
1080
- report: null,
1081
- };
1082
- }
1083
- if (latest.approvalStatus !== "approved") {
1084
- return {
1085
- approved: false,
1086
- detail: `latest report ${path.basename(latest.filePath)} has approvalStatus=${latest.approvalStatus ?? "<missing>"}`,
1087
- report: latest,
1088
- };
1089
- }
1090
- return {
1091
- approved: true,
1092
- detail: `approved via persisted report ${path.basename(latest.filePath)}${latest.approvedAt ? ` (approved at ${latest.approvedAt})` : ""}`,
1093
- report: latest,
1094
- };
1095
- }
1
+ // Re-export shim. The implementation moved to
2
+ // ./understanding-before-execution/ (structural concentration slice 2,
3
+ // agent-tasks 348a4d42), split into one sibling file per concern:
4
+ // ledger.ts, markers.ts, persisted-reports.ts, lifecycle.ts,
5
+ // task-markers.ts, post-tool-use-boundary.ts, active-claim.ts, composed
6
+ // by index.ts. Kept at this path so every existing import across the repo
7
+ // (hook-pre-tool-use.ts, hook-codex-pre-tool-use.ts,
8
+ // cli/approve/understanding.ts, tests, etc.) keeps working unchanged.
9
+ export * from "./understanding-before-execution/index.js";
1096
10
  //# sourceMappingURL=understanding-before-execution-runtime.js.map