@lannguyensi/harness 0.60.0 → 0.62.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 (89) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/dist/cli/apply/install-codex-config.d.ts +24 -7
  4. package/dist/cli/apply/install-codex-config.js +353 -81
  5. package/dist/cli/apply/install-codex-config.js.map +1 -1
  6. package/dist/cli/approve/understanding.d.ts +31 -6
  7. package/dist/cli/approve/understanding.js +197 -56
  8. package/dist/cli/approve/understanding.js.map +1 -1
  9. package/dist/cli/bounded-stdin.d.ts +40 -0
  10. package/dist/cli/bounded-stdin.js +111 -0
  11. package/dist/cli/bounded-stdin.js.map +1 -0
  12. package/dist/cli/doctor/settings-drift.js +12 -2
  13. package/dist/cli/doctor/settings-drift.js.map +1 -1
  14. package/dist/cli/gc/index.d.ts +6 -2
  15. package/dist/cli/gc/index.js +22 -7
  16. package/dist/cli/gc/index.js.map +1 -1
  17. package/dist/cli/index.js +15 -14
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/pack/auto-approve-path.js +39 -4
  20. package/dist/cli/pack/auto-approve-path.js.map +1 -1
  21. package/dist/cli/pack/hook-bootstrap.d.ts +59 -5
  22. package/dist/cli/pack/hook-bootstrap.js +67 -19
  23. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  24. package/dist/cli/pack/hook-branch-protection.d.ts +6 -0
  25. package/dist/cli/pack/hook-branch-protection.js +11 -2
  26. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  27. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +6 -0
  28. package/dist/cli/pack/hook-codex-pre-tool-use.js +20 -2
  29. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  30. package/dist/cli/pack/hook-post-merge-gate.d.ts +6 -0
  31. package/dist/cli/pack/hook-post-merge-gate.js +17 -2
  32. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  33. package/dist/cli/pack/hook-pre-tool-use.d.ts +7 -0
  34. package/dist/cli/pack/hook-pre-tool-use.js +26 -4
  35. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  36. package/dist/cli/pack/hook-runtime-reality.d.ts +2 -0
  37. package/dist/cli/pack/hook-runtime-reality.js +48 -8
  38. package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
  39. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +6 -0
  40. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +11 -2
  41. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  42. package/dist/cli/pack/hook-solution-acceptance.d.ts +6 -0
  43. package/dist/cli/pack/hook-solution-acceptance.js +11 -2
  44. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  45. package/dist/cli/pack/hook-subagent-start.d.ts +2 -0
  46. package/dist/cli/pack/hook-subagent-start.js +21 -4
  47. package/dist/cli/pack/hook-subagent-start.js.map +1 -1
  48. package/dist/cli/policy/intercept.d.ts +2 -0
  49. package/dist/cli/policy/intercept.js +57 -21
  50. package/dist/cli/policy/intercept.js.map +1 -1
  51. package/dist/cli/record/index.js +97 -21
  52. package/dist/cli/record/index.js.map +1 -1
  53. package/dist/cli/session-start/branch-check.js +4 -1
  54. package/dist/cli/session-start/branch-check.js.map +1 -1
  55. package/dist/cli/session-start/index.d.ts +0 -15
  56. package/dist/cli/session-start/index.js +4 -62
  57. package/dist/cli/session-start/index.js.map +1 -1
  58. package/dist/cli/session-start/shared-options.d.ts +15 -4
  59. package/dist/cli/session-start/shared-options.js +13 -15
  60. package/dist/cli/session-start/shared-options.js.map +1 -1
  61. package/dist/cli/session-start/stale-base-check.js +4 -1
  62. package/dist/cli/session-start/stale-base-check.js.map +1 -1
  63. package/dist/cli/session-start/toolchain-parity.js +4 -1
  64. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  65. package/dist/io/atomic-write.d.ts +37 -1
  66. package/dist/io/atomic-write.js +61 -8
  67. package/dist/io/atomic-write.js.map +1 -1
  68. package/dist/io/display-path.d.ts +11 -0
  69. package/dist/io/display-path.js +29 -0
  70. package/dist/io/display-path.js.map +1 -0
  71. package/dist/policy-packs/builtin/post-merge-gate.js +5 -2
  72. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  73. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  74. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  75. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  76. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +127 -16
  77. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +192 -45
  78. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  79. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +3 -2
  80. package/dist/policy-packs/builtin/understanding-before-execution.js +4 -1
  81. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  82. package/dist/runtime/deletion-target-resolve.js +24 -1
  83. package/dist/runtime/deletion-target-resolve.js.map +1 -1
  84. package/dist/runtime/destructive-shell-floor.js +15 -1
  85. package/dist/runtime/destructive-shell-floor.js.map +1 -1
  86. package/dist/runtime/shell-word.d.ts +26 -0
  87. package/dist/runtime/shell-word.js +94 -7
  88. package/dist/runtime/shell-word.js.map +1 -1
  89. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.62.0] - 2026-10-03
11
+
12
+ ### Changed
13
+
14
+ - The publish workflow now installs npm with `npm install -g npm@^11.5.1` (the documented trusted-publishing floor) instead of the floating `npm@11`, and the publish step's two success-on-registry exits (the "already on the registry" idempotency check before the retry loop and the loop's "reached the registry despite a non-zero publish exit" branch) now probe `npm view <pkg>@<version> dist.attestations` and fail with an error when the attestation is empty (or when the probe itself fails), so a partially completed publish is no longer announced green. CI only; no package code change.
15
+
16
+ - CI: `release.yml` now passes step values into `run:` scripts through `env:` and shell variables instead of interpolating `${{ }}` expressions into the script text. No behavior change for normal tags and versions.
17
+
18
+ ### Upgrade notes
19
+
20
+ - `harness policy intercept` (the PreToolUse hook of the policy engine) now blocks when its stdin does not deliver a complete, closed event within 3000 ms of idle time, instead of continuing as an empty event. Claude Code and Codex close stdin after writing the event, so normal use is unaffected; `harness pause` still wins. A hand-written registration of `harness policy intercept` on a non-PreToolUse event whose stalled read names no event gets the same block.
21
+
22
+ - `harness apply --runtime codex --install` now refuses three legacy config shapes it used to install over (exit 1, file untouched, each refusal names line numbers and what to change): a config with two generated-header lines whose first one has a foreign table below it and whose second one sits above a hook table; a key or value line between a legacy block's opening line (source prefix or generated header) and its first hook table; a `# harness hook:` comment line directly above a hook table when no BEGIN marker, source-prefix line or generated-header line opens a block around it. Each of these used to leave a harness hook running next to the fresh block or delete an operator key. A table of your own that carries a `# harness hook:` comment is refused too; reword the comment.
23
+
24
+ ### Fixed
25
+
26
+ - **The understanding gate's report-hash scan now has a total budget (bytes read, with a per-entry floor) and reads the newest report first; both approval producers refuse a report whose approval rewrite would exceed the size cap; the `--force` help names what it overrides and what it does not** (task `805be2af`, follow-up to `fa423e9b`, #586; source commits e24ebce and de60a8e). (1) Decision on the volume residual: fail closed instead of keeping the documented residual. The per-file cap of 1 MiB bounded each report file, not their number, so 160 planted 1 MiB key-heavy files took 15.9 to 17.6 s to scan (two earlier measurements), past the 15 s PreToolUse budget, which the runtime treats as an allow. The scan now takes the `*.json` entries in descending name order (report names start with a timestamp, so the newest first, where the approved report usually is) and charges each entry the larger of the bytes it read and a 4 KiB floor (`MIN_SCAN_ENTRY_COST_BYTES`, about one real report), whatever its read returned (a directory named `*.json`, a file over the cap, a FIFO and an unreadable file read nothing but still cost an open). It stops once the charges add up to `MAX_HASH_SCAN_BYTES` (32 MiB) without finding the signed content, so it opens at most 8192 entries and reads about 32 MiB (the last file read may add up to the 1 MiB per-file cap); both PreToolUse runtimes (Claude Code `pre-tool-use`, Codex `codex-pre-tool-use`, through `verifyMatchedMarkerReport`) then deny with the existing mismatch reason plus a note that the directory holds more report data than the scan budget covers. A first version charged only the bytes of files it read; an adversarial review showed that 400k planted `{}` files (no bytes to charge) still took about 22 s of CPU in the scan and kept the allow-by-timeout path open through the entry count, which the floor closes for the hash scan only (see the evidence-read residual below). A time slice was rejected: its verdict would depend on machine load, a budget of bytes and entries is deterministic and testable; a separate entry cap was rejected as the same bound with a second knob; the 4 KiB floor keeps the capacity for real reports (about 3.6 KiB each) near 8192 instead of the 9000 a pure byte budget gave. Measured with an in-process scan harness (task 805be2af) (the scan alone, in process, one run each, one machine, report of about 3.6 KiB; the machine load average was about 24 for the last run, so wall times are inflated): 100, 1000 and 5000 realistic reports (0.4, 3.5, 17.8 MiB) took 0 to 10 ms, 1 to 61 ms and 3 to 230 ms before the change, 0 to 4 ms, 1 to 31 ms and 4 to 214 ms with the bytes-only budget and 1 to 8 ms, 1 to 351 ms and 14 to 1288 ms with the floor (each range spans the approved report being the oldest or the newest), all allowing in every run. 160 planted 1 MiB key-heavy files (159.4 MiB) took 10.0 to 15.2 s before and 1.9 s with the bytes-only budget; with the floor the scan stops after 33 opens (3.4 s of CPU, 13.6 s wall under that load) and denies where the 15 s budget used to turn it into an allow. Entry-count cases measured with an in-process scan harness counting opens and CPU time (counts opens and CPU time, not wall clock): 400k tiny files took 400000 opens and 21.9 s of CPU at the bytes-only budget and 8192 opens and 0.75 s with the floor, 100k tiny files 8192 opens and 0.24 s, 100k directories named `*.json` 8192 opens and 0.20 s, 100k files over the cap 8192 opens and 0.21 s, each denying with the budget note. Residuals: a legitimate approval whose report sits behind 8192 newer entries (or about 32 MiB of them) denies with the mismatch reason; `harness approve understanding` writes a new, newest report and recovers, but only when the newer entries are real reports (see below). The `readdir` and name sort of the directory itself are not charged, so a directory of hundreds of thousands of entries still costs that listing before the scan starts (inside the 0.75 s measured for 400k). Evidence-read residual (still open, the hook as a whole is not bounded by entry count): the hash scan is bounded (8192 entries, about 32 MiB, per-entry floor), but the evidence read that follows a refused marker (`checkPersistedReport` through `listPersistedReportsBounded`, shared with the no-marker path and called by both PreToolUse hooks) still opens and parses every `*.json` entry. Measured end to end with an in-process hook harness at 400k planted entries: the Claude Code hook took 408195 opens and 28.9 s of CPU, the Codex hook 22.0 s of CPU, both past the 15 s PreToolUse budget, which the runtime treats as an allow. The auto-approval precondition listing, the subagent-delegation lookup and the parse-error log lookup are further full-directory reads on the same hook path; a follow-up task bounds all of them; this task changes no code there. Recovery by re-approval works only when the newer entries are real reports: planted `*.json` names that sort after the timestamped report names (for example names starting with a letter) stay newer than any new report and keep denying until the operator removes them. A third, unchanged residual: `expirePersistedReport` (PostToolUse boundary expiry) rewrites an approved report with `expiredAt` and `expiredBy` added and no size check, so a report approved within about 100 bytes of the cap can end up over it, after which the gate reads no file for it and `harness approve understanding` refuses on that oversized entry until the operator removes or shrinks it. (2) `harness approve understanding` and the auto-approval path checked the size cap on the report as written, but the approval rewrite pretty-prints it and adds `approvedAt` and `approvedBy`, so a report accepted just under the cap could cross it afterwards and deny on every call, with neither re-approval nor `--force` able to clear it (a band of about 150 bytes). Both now render the rewrite first and refuse on its bytes: the CLI exits 1 before any marker, ledger tag or report change (also under `--force`), the auto path declines as `report invalid: size` and leaves the report `pending`. (3) `harness approve understanding --force` help now says what it overrides and what it does not: it also overrides the nesting-depth refusal (the marker is then signed with no content binding, `reportContentHash: null`) and never the size refusal (also on the pretty-printed approval rewrite), an unreadable report or one that is not a regular file. Tests pin the budget deny, the newest-first allow, an unchanged allow for 1000 and 5000 realistic reports, and a deny for entries past the per-entry floor (tiny files, directories, files over the cap) on both runtimes, and the cap-minus-50-byte report on the approve CLI (exit code 1, no ledger call) and on both auto paths.
27
+
28
+ - Restore test typechecking by completing approval marker fixtures, checking nullable report hashes and runtime fixtures, and discarding subprocess return values in void callbacks (9d74bae, #612).
29
+
30
+ - Codex install (`8c2cca88b07919c82d0e6d6da0dc3122f1a59443`) refuses operator content between BEGIN and the first hook table, marked orphan hooks outside its replacement range (including after foreign tables or END), and a separate later generated block above a canonical hook table. Refusals give line guidance and leave the config untouched. A canonical legacy block before a later BEGIN, uncommented respelled later blocks, and uncommented harness commands on the append path remain ownership boundaries; see `docs/okf/codex-adapter-parity-gaps.md`.
31
+
32
+ - `harness approve understanding` escapes successful ledger tags and marker-write diagnostics; `harness gc` escapes unreadable record paths and malformed delegation diagnostics. Stored identities, validation, and retention remain unchanged (#610).
33
+
34
+ - `harness approve understanding` escapes report-controlled parse-error summaries, approval status, mode, fallback creation times, resolved session ids and marker paths before terminal display. Guessed session ids retain their existing resolution and marker naming. `harness gc` also escapes in-flight approval timestamps. The shared display renderer now escapes Unicode format characters (including bidi overrides), U+2028 and U+2029 as JSON Unicode escapes (beba45f, #610).
35
+
36
+ - **`harness approve understanding` refuses, and `harness gc` reports, a planted oversized, non-regular or symlinked entry in the reports directory instead of reading it in full** (task `1ccfe922`, follow-up to `4b39022f`). Both commands listed the reports directory unbounded: a file of a few hundred megabytes was read whole, and one larger than the string limit was skipped silently, so approve signed a marker bound to no report (measured: a 600 MB planted `*.json` gave exit 0 and a marker). They now list through the bounded reader. Approve exits 1 before any side effect (no marker, ledger tag, report flip or task marker), naming each `*.json` entry that is over the 1 MiB cap, not a regular file (FIFO, directory), a symbolic link whatever it points at, or unreadable; `--force` does not override it (an oversized report used to be refusable only by validation, so `--force` could sign a marker bound to no report; that no longer happens). Its re-read of the selected report, its hash and the final report rewrite are bounded as well, open with `O_NOFOLLOW` (a link is refused by the open itself, with no earlier `lstat` to race), and refuse an oversized or non-regular report that is swapped in after the listing even under `--force`, which now covers content reasons only (a report nested too deeply still signs a marker bound to no report). Every report path that `harness approve understanding` prints (its refusals and its success output) and that `harness gc` prints is an escaped literal (`\u001b`, `\r`, `\n`, DEL and C1 bytes escaped, not stripped), so a file name chosen by the gated agent cannot write control bytes to the operator's terminal or forge a line. `harness gc` lists such an entry as unparseable, never reads it and never deletes it. A regular in-cap report signs the same marker as before. To recover from the refusal, remove or move the named file; see `docs/okf/understanding-gate-lockout-recovery.md`.
37
+
38
+ - **`harness policy intercept` blocks a PreToolUse call on a timed-out stdin read instead of allowing it** (task `aca3de04`, follow-up to `7dfdcaaf`). After the idle-bounded read timed out (3000 ms without a chunk) the intercept continued as an empty event, which matches no policy and allows, so a host that stalled past the bound and then wrote a complete gated event and closed got an allow where the policy would have blocked (the hook had already exited when the event arrived). A timed-out read now refuses the tool call after the operator pause check: a reason starting `stdin timeout:` that names the 3000 ms bound, the usual `decision: "block"` envelope with the PreToolUse `permissionDecision: "deny"` on stdout, exit 0, one `BLOCK` line on stderr, and no policy is evaluated, so it applies whatever the declared enforcement tiers are. The hook command carries no event name, so the event the read itself declares decides: a timed-out read whose text is a JSON object naming a hook event other than `PreToolUse` keeps the previous behaviour (the `using the N bytes read` note, then the event is evaluated like any other); anything else (nothing read, a truncated or unparseable prefix, a `PreToolUse` event, an object with no event name) is treated as the PreToolUse call the verb is registered for. A stdin that closes within the bound is decided exactly as before, and the empty-event note `continuing as an empty event` is no longer written by this verb.
39
+
40
+ - **`harness apply --runtime codex --install` refuses the legacy shapes it used to half-handle, its no-END refusal no longer offers a move that changes nothing, and a lookup that cost a parse per line stops at the scanned range** (task `c78a155b`, closes the residuals named after task `36d962d2`). (1) The install read only the first generated-header line: when a foreign table followed it, a genuine legacy block further down was never replaced and its hook kept running next to the fresh block. It now refuses when a later generated-header line sits directly above a `[[hooks.` table, naming both lines; a later block whose first hook table is not directly under its header line stays unrecognised (a documented boundary, as is a later source-prefix line and a second genuine block after the first). (2) A key or value line between a legacy block's opening line and its first hook table is not harness output, since a block harness wrote has only comment lines there; the replacement deleted it with the block, and for a key of an operator hook table the semantic net could not see the loss (it compares only what is outside the hook event arrays). The legacy paths now refuse and tell the operator to move the line above the opening line. The BEGIN path is unchanged: a key between the BEGIN line and the first hook table is still consumed with the block, the same reasoning as above applies to it, and it is tracked as a follow-up (a key after the first hook table is a field of that harness table and is consumed with it). Two shapes the semantic net used to catch first (a top-level key after a legacy source-prefix line, a generated header pasted inside a foreign table) now refuse here with this message. (3) On the append path a `# harness hook:` comment line directly above a hook table (`[[hooks.` or a respelled hook event header) left that table running next to the fresh block; it now refuses, naming the comment line and the table's header. The check is linear and parses nothing for a pair inside a multi-line string. (4) The no-END refusal offered "move the table below the last harness hook table" even when no harness-commented table follows it, so the table already sat there and the same refusal came back; that branch now offers only adding the END marker, and the move stays the only offer when a harness-commented hook table does follow. (5) The lookup that lets a generated header open a block searched the whole rest of the document and probed every `[[hooks.` line in it; it now stops at the end of the scanned range. Measured on a config with a generated header, `[tui]` and a multi-line string of N `[[hooks.` lines (wall time of one plan, the machine shared with other jobs): 363 ms at 2000 lines and 7088 ms at 8000 before, 1 ms and 31 ms after. The BEGIN, END and source-prefix searches over the whole document are not bounded by a scan and keep their cost for marker text in strings; that cost is unchanged by this change. Reproduced with the previous review rounds' shape harnesses at base and head: no shape got worse, the two append-path orphan shapes, the two stale-header shapes and the two operator-key shapes moved from installing wrongly to refusing.
41
+
42
+ - **`atomicWriteFile` creates its temp file exclusively under a random name, so a file planted at the temp name can no longer hang the writer or redirect the write** (task `4d28617e`, closes the write-path residual named under 0.61.0). The shared helper behind the approval markers, the persisted-report rewrites (the auto-approval consume step and the PostToolUse boundary expiry), the in-flight and delegation records, the active claim, the permission-mode observation, the parse-error log, the manifest and settings writers and their backups wrote `.<base>.<pid>.<ms>.tmp` next to the target and opened it with plain `"w"`: a FIFO planted at that name (a name an agent can read off the process table and the clock) blocked the open until the host's hook timeout, so the understanding gate's consume step, which writes into the agent-writable reports directory, left the report pending and the call proceeded as a non-blocking hook error, and a planted symlink was written through to wherever it pointed. The temp name now carries 64 random bits (`crypto.randomBytes`) and is opened with `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`: anything already at the chosen name (a FIFO, a symlink including a dangling one, a regular file) makes the call throw `EEXIST` at once, never blocking and never writing through the link, and the planted entry is left alone (a path the call did not create is never removed). `O_NOFOLLOW` is not defined on Windows; there `O_EXCL | O_CREAT` alone carries the refusal (it never follows a symlink on create). After the temp file exists, any failure (write, fsync, close, rename) removes it again. Failure postures are unchanged because every caller already treats a throw as a failed write: the consume step declines with the report left `pending` (`auto-approval declined: could not consume the report (...)`), the boundary expiry is skipped with a `persisted-report expiry skipped (...)` note, `harness approve understanding` and the other marker writers report the failed write, and the settings and manifest writers abort before replacing anything. A successful write is unchanged: same bytes, the mode each caller requests (0o644 when none is given), subject to the umask, same rename. Tests: helper unit tests (planted regular file, symlink, dangling symlink and FIFO at the chosen name, the flags, the suffix, cleanup after a failed rename) and built-CLI cases on both the Claude and the Codex PreToolUse hook (FIFOs planted at every former `pid.millisecond` name no longer hang the hook and the report is still minted; a FIFO, a symlink or a regular file planted at the chosen name declines within the bound with the report left pending and nothing written through or removed). Still open from the 0.61.0 entry: the total volume of planted files and the unbounded `harness approve understanding` listing of an oversized planted report.
43
+
44
+ ## [0.61.0] - 2026-10-02
45
+
46
+ ### Upgrade notes
47
+
48
+ - Re-run `harness apply` once. The understanding gate's SubagentStart hook command is now emitted with the `UNDERSTANDING_GATE_REPORT_DIR=<dir>` prefix like the other report-reading hooks; without it the new report-hash cross-check at subagent start cannot find the reports of a global manifest.
49
+ - Every PreToolUse gate of the pack hooks (`pre-tool-use`, `codex-pre-tool-use`, `branch-protection`, `solution-acceptance`, `solution-acceptance-writeguard`, `post-merge-gate`, `runtime-reality`) now blocks when its stdin does not deliver a complete, closed event within 3000 ms of idle time, instead of waiting for the host's hook timeout. Claude Code and Codex close stdin after writing the event, so normal use is unaffected; `harness pause` still wins.
50
+ - `harness apply --runtime codex --install` refuses a `~/.codex/config.toml` the TOML parser cannot read when the parse error sits before an already current harness block (exit 1, file untouched); it used to report "already up to date". Fix the TOML and re-run.
51
+
52
+ ### Fixed
53
+
54
+ - **The `harness pack hook` verbs no longer hang when stdin is never closed, and the PreToolUse gates block on a timed-out read** (task `7dfdcaaf`, follow-up to `c8cfc110`). The shared reader in `src/cli/pack/hook-bootstrap.ts` (used by the 15 pack hook verbs that read an event on stdin) and the reader in `src/cli/pack/hook-runtime-reality.ts` read to end of input, so an open pipe that never closes held the hook until the host's own hook timeout. Both now share the idle-bounded reader in `src/cli/bounded-stdin.ts` (3000 ms without a chunk; a slow but live pipe is not cut off). A PreToolUse gate (`pre-tool-use`, `codex-pre-tool-use`, `branch-protection`, `solution-acceptance`, `solution-acceptance-writeguard`, `post-merge-gate`, `runtime-reality`) fails closed on a timed-out read, whether nothing or part of the event arrived: it blocks the tool call with a reason starting `stdin timeout:` that names the 3000 ms bound (Claude Code gates emit their usual block envelope and exit 0, `codex-pre-tool-use` exits 2 with the reason on stderr, `runtime-reality` emits its deny envelope and exits 2), instead of applying its empty or malformed event handling; the operator pause still wins. Without this, a host that stalled for more than 3 s and then wrote a complete gated event and closed would have turned a block into an allow. Every other pack hook (PostToolUse, Stop, SubagentStart, SubagentStop, UserPromptSubmit and the producers) treats a timeout as the bytes it read: one stderr note (`no complete event JSON on stdin within 3000 ms (stdin never closed); continuing as an empty event`, or `stdin did not close within 3000 ms of the last data; using the N bytes read`) and it carries on with the text read so far. Nothing changes for input that arrives and closes within the bound; hook output formats are unchanged.
55
+
56
+ - **The understanding gate's hook-reachable reads of the reports directory and parse-error logs are bounded (the evidence read, the PostToolUse boundary expiry, the auto-approval precondition and its consume step, the subagent-delegation lookup, the parse-error lookup), so planted files can no longer hang or kill the hooks on those reads (the consume step's write is not covered, see the last sentence of this entry); the operator commands `harness approve understanding` and `harness gc` stay unbounded on purpose** (task `4b39022f`, closes residual (4) named under 0.60.0 for the evidence read). The read behind `checkPersistedReport` (the block diagnostic of both PreToolUse hooks, on the no-marker path and after a refused marker) and behind `expirePersistedReport` (the PostToolUse boundary expiry) listed the reports directory with a separate `stat` and then `readFileSync`: a `*.json` symlink flipped to a FIFO between the two hung the hook (measured 4/20 runs on the Claude hook, 1/20 on the Codex hook), and one file of a few hundred megabytes gave a V8 fatal error and no decision on both; each ends as a non-blocking error for the runtime, i.e. the call proceeds. The same unbounded listing sat in the auto-approval precondition and the subagent-delegation lookup (reached on the same paths whenever `auto_approve` is configured, which the `harness init` default is for `bypassPermissions`), and the parse-error lookup read `parse-errors/*.log` with a `stat` and `readFileSync` too. All of them now open each entry once through `readReportFileBounded` (read-only, `O_NONBLOCK`, `fstat` on the descriptor, regular files up to 1 MiB, read through the descriptor) and skip anything else (a FIFO, a directory, a device, a symlink to one, a file over the cap, an unreadable file, one that grows while read) as if it were not in the directory. The auto-approval precondition also declines, so the call stays blocked, when its listing had to skip any entry: an oversized or unreadable newest report never lets it fall back to an older pending one. Decisions are unchanged: the same deny with the same reason on the no-marker path and after a refused marker, with a FIFO or a 2 MiB file next to the reports; a regular report within the cap is read, sorted and reported exactly as before. Consequences: a report over 1 MiB is no longer picked up as the session's evidence, so a block diagnostic that would have named it (`unsigned persisted-report approval rejected: ...`) now reads `no reports found`; the boundary expiry no longer rewrites such a file and acts on the newest report that was within the cap (when the newest approved report is oversized, the next in-cap one is expired); a parse-error log over the cap is not surfaced. `harness approve understanding` and `harness gc` keep the unbounded listing on purpose, so an oversized report is still found and refused or aged out. The auto-approval consume step no longer re-reads the report by path: it rewrites the text the precondition read through the bounded reader, and the write is a temp file renamed over the entry, which never opens what a symlink points at. Before, a report symlink retargeted to a FIFO or a few-hundred-megabyte file between the precondition and the consume hung or killed the hook in about a third of the attempts on both runtimes. `harness approve understanding` (including `--force`) still reads the report by path. Still open: the write path of the consume step, whose atomic write opens its temp file in the agent-writable reports directory without `O_EXCL`/`O_NOFOLLOW`, so a FIFO planted at the temp name can still hang the hook (pre-existing, tracked separately); the total volume of planted files (a separate residual); an agent-planted oversized report crashes `harness approve understanding` itself until the file is deleted (`docs/okf/understanding-gate-lockout-recovery.md`).
57
+
58
+ - **`harness apply --runtime codex --install` no longer refuses a legacy block with no hook table in it, its no-END refusal no longer offers an END position that keeps a harness hook running, and marker text inside a value is no longer read as a managed-block marker** (task `36d962d2`, follow-up to `e8f4fc03`). (1) A legacy config (no BEGIN marker) whose source-prefix line, with or without the generated header, sits directly above an operator table was refused when that table was a hook event table spelled without the `[[hooks.` prefix (`[[ hooks.Stop ]]`) and no END followed, or when a surviving END followed any foreign table: the split-block zone started at the block's own opening line, which is itself a split-block marker. The zone now starts after that line, so these configs install (the operator table is kept and named, an orphan END is excised, a second install gives the same bytes); a harness marker past the opening line still refuses. (2) When no END marker exists and a harness-commented hook table follows the named table, in either spelling (`[[hooks.Stop]]` or `[[ hooks.Stop ]]`), the refusal said to add an END right after the last harness hook table (before the named table), which contradicts itself and, followed by its parenthetical, left the later harness table running next to the fresh block. That option is now offered only when no harness-commented hook table follows; otherwise the refusal offers moving the named table below the last harness hook table. (3) Every managed-block marker (the BEGIN and END lines, the source-prefix line, the generated header and the `# harness hook:` comment) is now found by one rule: a whole trimmed line (equal to BEGIN or END, starting with the others) at the top level of the document. Before, BEGIN was found anywhere in the text, so BEGIN text in a string value or a trailing comment of an operator hook table made the install silently delete operator content after it (a key, or the whole next operator hook table), and BEGIN text in a multi-line string had the fresh block spliced into that string, growing on every install; the source-prefix and generated-header text opened a legacy block at the closing line of a multi-line string or at a trailing comment; an END comment line inside a multi-line array ended the block there or was excised as a stray END; and a `# harness hook:` line inside a string was reported as a removed hook. Marker text in a value (a single-line or multi-line string, a trailing comment, an inline table, an element or a comment line of a multi-line array) is now operator content: the install writes a fresh block and leaves that value as it was, a second install gives the same bytes, an END line that no block owns stays, and BEGIN text in a value is no longer refused as a second BEGIN marker. The same top-level line rule now decides whether a generated-header line opens a legacy block, which needs a hook table header line (`[[hooks.`, the older `[[hooks.pre_tool_use]]` included) after it: `[[hooks.` text in a string value, a multi-line string line or a comment line below a generated-header comment in an operator hook table opened that block, and the install replaced the operator's keys after the header line with the fresh block; the block is now appended and those keys stay. Two marker searches still match marker text as substrings: the split-block refusal's candidate list, which can only refuse, and the trailing comment run at the end of the scan, whose lines are whole top-level comment lines. Known boundary, unchanged: that run (blank and comment lines ending the file after a block with no END marker) is consumed with the block when the source-prefix, generated-header or `# harness hook:` text appears anywhere in it, so an operator comment there that mentions such text mid-line is removed. Behaviour change for a config the TOML parser used by harness cannot read: when its parse error sits before an already current block, the install reported "already up to date" and now refuses (exit 1, file untouched, no backup), since a marker line after the parse error is not at the top level of a readable prefix; a parse error after a current block still reports up to date. Tests pin all three, the generated-header shapes and the unreadable-config refusal, plus the substring refusal and the escaped `hooks` root key on the legacy no-END path. The stale `# Harness Codex hook wiring. Generated source:` and `# Generated by harness apply --runtime codex.` lines of an empty legacy block still stay where they were (as before for a block followed by a foreign table); they are not removed.
59
+
60
+ - **`harness record dogfood` refuses a session id with leading or trailing whitespace** (task `eed7c036`). The verb checked the trimmed session id for the `dogfood:<session>` content but wrote the fact into the untrimmed session, so `--session ' s1'` produced content `dogfood:s1` in session `' s1'`. It now exits `64` naming `--session` and writes nothing, consistent with the other record validation, rather than trimming, so content and target can never name different ids. The other record verbs and the ledger write are unchanged. Docs only alongside: the `ResolvePackOptions.reportsDir` comment says the `UNDERSTANDING_GATE_REPORT_DIR` prefix goes on each report-reading hook command (not every contributed hook), and the in-flight subagent dogfood README marks `payloads/rendered-settings.json` as a historical capture that predates the prefixed `subagent-start` command.
61
+
62
+ - **`harness doctor` no longer aborts on a malformed `.last-apply` when the understanding pack is declared** (task `3128df7a`). The settings-drift check read the record unguarded, so unparseable JSON or a record without a `files` map threw out of doctor. It now skips its comparison and adds a warning naming the file (`<generated dir>/.last-apply is unreadable (...); settings drift not checked`), which counts toward `warningCount`, the same shape as the runtime-selection fallback. A well-formed record gives the same output as before.
63
+
64
+ - **`harness record review` / `review-subagent` / `dogfood` reject values that would plant a second ledger tag** (task `237cc609`). The verbs write their fact as space-separated tag text and the gates match tags by substring, so `--branch "x review-subagent:master"`, `--base "x review-subagent:master"` or `--verdict "ok review-subagent:master"` also satisfied another branch's `review-subagent-before-pr-create-bash` gate. Every ref-like or id-like value embedded in a tag (`--branch`, including a branch resolved from git; `--base`; `--task`; `--pr`; the session id `record dogfood` writes as `dogfood:<session>`) is now refused when the trimmed value contains a `:` or any ASCII whitespace, which git ref names forbid anyway, so no real branch is refused and every tag namespace, custom ones included, is closed for these values without a list (free text is checked only against the six gate-opening namespaces listed here, so a custom namespace there is not refused); Unicode whitespace without a `:` stays accepted. Free text (`--verdict` and the summaries of all three verbs) stays free and is rejected (not rewritten, so the audit text is never altered) only when it carries `review-subagent:`, `review:`, `dogfood:`, `preflight:`, `risk-approved:` or `risk-override:` immediately followed by a non-whitespace character; prose like `code-review: approved` is kept. Each refusal exits `64` naming the flag, and nothing is written. Task, PR and branch values still share tag namespaces (`record review-subagent --task master` writes `review-subagent:master`, `record review --pr master` writes `review:master`), so a value that is itself a valid branch name is not stopped. Hardening of the advisory ledger only: the substring matcher and `ledger_add` are unchanged, and docs/CLI.md "Tag-injection validation" states the rules.
65
+
66
+ - **`harness pack hook subagent-start` no longer mints an in-flight subagent record under an approval whose report was edited** (task `dac02d5c`, closes residual (2) named under 0.60.0). After a parent marker matched, the hook now applies the same gate-read report-hash cross-check as both PreToolUse hooks (`verifyMatchedMarkerReport`, task marker first, session marker as fallback) before `writeInflightRecord`; when no report file carries the signed content any more it writes no record and says so on stderr (`approval refused, no report in the reports directory matches the content ...; no in-flight record for agent <id>`), so the subagent has nothing to present once the parent marker stops matching (claim switch, `max_age`) and blocks. An intact approval and a null-hash marker mint exactly as before; a task marker that fails the check while the session marker behind it verifies mints a record that names the session marker. The record format and its 24 h staleness window are unchanged. Codex has no SubagentStart wiring, so this is the Claude hook only. **Upgrade:** re-run `harness apply` once. The SubagentStart hook command is now emitted with the same `UNDERSTANDING_GATE_REPORT_DIR=<manifest-anchored dir>` prefix as the other report-reading hooks, so it reads the directory `harness approve understanding` wrote to; a settings file generated before this change keeps the bare command, which falls back to `<cwd>/.understanding-gate/reports`, a directory that usually does not exist, so the cross-check there neither sees the reports nor refuses an edited one.
67
+
68
+ - **A destructive command no longer escapes the risk floor or the deletion gate because one word carries a NUL-decoding ANSI-C escape** (task `5cc64860`, follow-up to `241d9e9e`). bash truncates a `$'...'` run at a NUL (measured on GNU bash 3.2.57, `printf '[%s]' <word> | od -c`; the first report measured 5.1.16), so `git push $'--force\0' origin`, `git reset $'--hard\0'`, `find / $'-delete\c@'`, `$'dd\0' of=/dev/sda` and `$'rm\0' -rf /home/x` run as their plain twins. The decoder keeps a literal U+0000 in the decoded word, so every exact comparison (a whole flag, a head name, a subcommand) in `src/runtime/destructive-shell-floor.ts` and `src/runtime/deletion-target-resolve.ts` missed them: the floor reported nothing (the plain twins are `high`, `high`, `critical`, `critical`) and the deletion gate made no decision for the `rm` form. Both scanners now examine the command a second time with every `$'...'` run that carries such an escape replaced by a single-quoted literal of the value bash passes, and keep both results: the floor keeps the hits of both scans, the resolver combines both verdicts (unresolvable if either reading is), so the second reading can only add a verdict, never remove one. `shell-word.ts` gains `decodeShellWordTruncatingNul` (each run cut at its first escape that decodes to NUL or any `\c` escape, the text around the run kept: `x$'a\0b'y` is `xay`) and `truncateNulRuns` (the rewrite of a whole command text, or `null` when no run carries such an escape, so a command without one is scanned once as before); the rewrite happens before the boundary split, so a `;`, `|` or `&` inside the cut-off part of a run (`git push $'--force\0;' origin`) disappears with it instead of tearing the word. `decodeShellWord`, its decoded values and the read-only classifier are unchanged. This is the comparison-only truncation variant, not a blanket fail-closed on any NUL word: a NUL escape in a harmless place (`echo $'a\0b'`, `git push $'origin\0' main`) stays as unclassified as before. It also gates a target that a NUL cuts back to a safe root itself (`rm -rf $'/tmp/\0x'` reaches bash as `/tmp/`, the safe root itself, which was not gated before). Over-cutting is the accepted direction: `\c` escapes other than `\c@` and `\u`/`\U` escapes that bash 3.2.57 leaves literal are cut too, which only adds a candidate reading, and a `$'` that bash reads inside single quotes is rewritten like a real run. A run without a closing quote is left as written. Measured base versus head over 276 commands (207 with a NUL escape: the read-only NUL corpus of `241d9e9e`, every NUL string in the runtime tests, the five commands above; 69 without one, among them the plain twins): no floor hit, no classified severity and no deletion verdict is lower at head, the 69 commands without a NUL escape give identical results, and 105 NUL commands are stricter; 27 of 27 sample words decode through the rewrite to exactly the bytes `/bin/bash` 3.2.57 passes. Pinned in `tests/runtime/destructive-shell-floor.test.ts`, `tests/runtime/deletion-target-resolve.test.ts`, `tests/runtime/intercept-deletion-gate.test.ts` and `tests/runtime/shell-word.test.ts`.
69
+
70
+ - **`harness policy intercept`'s stdin-timeout note now says what it does when nothing was read** (task `c5831891`, follow-up to `c8cfc110`). The shared note for an idle, empty stdin ended with `falling back to the default session resolution`, which fits the session-start producers but not `policy intercept`, which continues as an empty event. `stdinTimeoutNote` takes an optional caller tail; intercept passes `continuing as an empty event`, and the session-start producers' text is unchanged byte for byte. The idle-stdin tests in `tests/cli/bounded-stdin.test.ts` also restore the working directory in their `afterEach` cleanup before the temp dir is removed, so a regressed bound fails only the bound tests instead of breaking every later test with a deleted cwd.
71
+
72
+ - **`harness session-start branch-check`, `stale-base-check`, `toolchain-parity` and `harness policy intercept` no longer hang when stdin is never closed** (task `c8cfc110`). They awaited stdin's `end` with no bound, so a backgrounded compound command (or any caller holding an open pipe) left the hook idle forever. All five stdin-reading hook-style entries (these four and `preflight`) now share one idle-bounded reader (`src/cli/bounded-stdin.ts`): the 3 s idle timer restarts on every chunk, so a slow but live payload is still read in full, and a timeout writes a stderr note and carries on with what was read (an empty read is an empty event, partial text is parsed). `policy intercept` keeps its fail posture: malformed event JSON still exits 0 with no decision and a stderr note. Hook output formats are unchanged. The pack hooks (`hook-bootstrap.ts`, `runtime-reality`) keep their own readers.
73
+
10
74
  ## [0.60.0] - 2026-10-01
11
75
 
12
76
  ### Upgrade notes
package/README.md CHANGED
@@ -98,7 +98,7 @@ steps).
98
98
 
99
99
  ## Status
100
100
 
101
- The current release is `v0.60.0`. All seven planned phases have
101
+ The current release is `v0.62.0`. All seven planned phases have
102
102
  shipped; phase acceptance criteria are in
103
103
  [`docs/ROADMAP.md`](docs/ROADMAP.md), and what shipped in each version
104
104
  is in [`CHANGELOG.md`](CHANGELOG.md).
@@ -163,20 +163,37 @@ export declare function createTopLevelProbe(text: string): (lineStart: number) =
163
163
  * point at that marker; when there is no END marker anywhere in the
164
164
  * document (`to` is `text.length`), telling the operator to move something
165
165
  * below a marker that does not exist would be nonsensical, so a different
166
- * fix (add one, or move the table below the last harness hook table) is
167
- * offered instead (task 6a037359). When the named table is a hook event
168
- * table spelled without the `[[hooks.` prefix, the guidance offers deleting
169
- * it if harness wrote it, since the scan reads such a table as foreign with
170
- * or without a comment line above it (task e8f4fc03). The legacy paths call
166
+ * fix is offered instead (task 6a037359): adding one right after the last
167
+ * harness hook table, unless a harness-commented hook table follows the named
168
+ * table (`harnessHookTableFollows`), since an END placed before the named table
169
+ * would leave that later table running next to the fresh block, and then only
170
+ * moving the named table below the last harness hook table is offered (task
171
+ * 36d962d2). With no such table after it the named table already sits below
172
+ * the last harness hook table, so that move is never offered next to the END
173
+ * fix: it would change nothing and bring the same refusal back.
174
+ * When the named table is a hook event table spelled without the `[[hooks.`
175
+ * prefix, the guidance offers deleting it if harness wrote it, since the scan
176
+ * reads such a table as foreign with or without a comment line above it
177
+ * (task e8f4fc03). The legacy paths call
171
178
  * this too, so the substring matching above applies there as well: a value
172
- * holding marker text refuses. `blockOpening` names the line the block
179
+ * holding marker text refuses. This candidate list and the trailing comment
180
+ * run of `scanOwnedContentEnd` are the two marker searches that do not go
181
+ * through `createMarkerLineProbe`, on purpose. This one can only refuse,
182
+ * never install, so matching marker text in a value costs a refusal, never
183
+ * operator content. The comment run's lines are whole top-level comment
184
+ * lines, so marker text there is never inside a value, but that run (blank
185
+ * and comment lines ending the file after a block with no END marker) is
186
+ * consumed when the source prefix, the generated header or the
187
+ * `# harness hook:` text appears anywhere in it, so an operator comment
188
+ * there that mentions such text mid-line is removed with the block.
189
+ * `blockOpening` names the line the block
173
190
  * starts at in the delete-the-lines fix: the BEGIN marker by default; a
174
191
  * legacy config (no BEGIN marker) whose END marker survived passes its
175
192
  * source-prefix line or generated header instead, since a BEGIN marker it
176
193
  * does not have is no line to delete from (task 01053b27). For the generated
177
194
  * header the fix deletes that line and the END marker line too: a generated
178
195
  * header left alone above END no longer marks a harness block
179
- * (`findManagedRange` needs a `[[hooks.` line after it), so the install
196
+ * (`findManagedRange` needs a top-level `[[hooks.` header line after it), so the install
180
197
  * would add a fresh block and leave that pair behind as an orphan. Exported for
181
198
  * direct tests only: the scan
182
199
  * that feeds it never hands it a line that is not a real header, so its own