@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
@@ -37,7 +37,7 @@
37
37
  // harness gate; `harness approve understanding` is the approval path.
38
38
  export { APPROVED_LEDGER_TAG_PREFIX, approvedLedgerTagFor, isPolicyDecisionRow, matchLedgerEntries, } from "./ledger.js";
39
39
  export { APPROVAL_MARKER_DIRNAME, approvalMarkerPathFor, writeApprovalMarker, checkApprovalMarker, clearApprovalMarker, } from "./markers.js";
40
- export { REPORTS_DIR_ENV, defaultReportsDir, reportsDirForManifest, listPersistedReports, TOLERANT_FALLBACK_MAX_AGE_MS, TOLERANT_FALLBACK_FUTURE_SKEW_MS, selectReportForSession, selectNewestStrictSessionReport, findLatestReportForSession, expirePersistedReport, checkPersistedReport, canonicalReportHash, canonicalReportHashOfFile, MAX_HASHED_REPORT_BYTES, readReportFileBounded, hashReportFile, verifyApprovedReportHash, } from "./persisted-reports.js";
40
+ export { REPORTS_DIR_ENV, defaultReportsDir, reportsDirForManifest, listPersistedReports, listPersistedReportsBoundedWithSkips, TOLERANT_FALLBACK_MAX_AGE_MS, TOLERANT_FALLBACK_FUTURE_SKEW_MS, selectReportForSession, selectNewestStrictSessionReport, findLatestReportForSession, expirePersistedReport, checkPersistedReport, canonicalReportHash, canonicalReportHashOfFile, MAX_HASHED_REPORT_BYTES, readReportFileBounded, hashReportFile, verifyApprovedReportHash, } from "./persisted-reports.js";
41
41
  export { parseApprovalLifecycle } from "./lifecycle.js";
42
42
  export { AUTO_APPROVED_BY_PREFIX, CLAUDE_CODE_HARNESS, autoApprovedByFor, parseAutoApprovedBy, autoApprovedLedgerTagFor, parseAutoApprove, permissionModeAllowed, harnessAllowed, AUTO_APPROVE_HARNESS_VALUES, DEFAULT_AUTO_APPROVE_HARNESSES, DEFAULT_REPORT_SCAN_MAX_WAIT_MS, REPORT_SCAN_MAX_WAIT_CEILING_MS, parseReportScanMaxWait, } from "./auto-approve.js";
43
43
  // D-004 shipped default (task 8f637efd): the one `auto_approve` snippet
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/policy-packs/builtin/understanding-before-execution/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/policy-packs/builtin/understanding-before-execution/index.ts"],"names":[],"mappings":"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"}
@@ -55,16 +55,16 @@ export declare function defaultReportsDir(cwd?: string): string;
55
55
  export declare function reportsDirForManifest(manifestPath: string): string;
56
56
  /**
57
57
  * List persisted reports under `dir`, newest-first by creation time
58
- * (JSON `createdAt`, falling back to the filename ISO prefix, falling
59
- * back to mtime). Missing directory returns []. Any I/O error on a
60
- * single file is silently skipped; the caller falls through to the
61
- * ledger result.
58
+ * (JSON `createdAt`, then the filename ISO prefix, then mtime). Missing
59
+ * directory returns []; an I/O error on a single file skips it. Reads each
60
+ * regular file IN FULL, so it must not list the agent-writable reports
61
+ * directory (use {@link listPersistedReportsBoundedWithSkips}); no `src`
62
+ * caller remains, it stays for its tests and the pack's export surface.
62
63
  *
63
64
  * Creation time, NOT mtime, is the sort key: `harness approve
64
65
  * understanding` rewrites the report it flips, which bumps mtime and
65
66
  * made an old just-approved report sort as the freshest
66
- * (harness-discovery C1). mtime survives only as the last-resort
67
- * fallback for files that carry neither timestamp.
67
+ * (harness-discovery C1). mtime is the last-resort fallback.
68
68
  */
69
69
  export declare function listPersistedReports(dir: string): PersistedReport[];
70
70
  /**
@@ -276,6 +276,38 @@ export declare function describeBoundaryExpiry(report: Pick<PersistedReport, "ap
276
276
  * producers refuse to approve such a report.
277
277
  */
278
278
  export declare const MAX_HASHED_REPORT_BYTES: number;
279
+ /**
280
+ * Budget the gate-read hash scan spends before it gives up (32 MiB), charged
281
+ * per `*.json` entry as the larger of the bytes read and
282
+ * {@link MIN_SCAN_ENTRY_COST_BYTES}. The per-file cap bounds one file, not
283
+ * their number: 160 planted 1 MiB key-heavy files took 15.9 to 17.6 s of scan
284
+ * time, past the 15 s PreToolUse budget, and a hook past its budget is a
285
+ * non-blocking error for the runtime (an allow). Charging bytes alone left the
286
+ * entry count open: entries that cost no bytes (tiny files, directories named
287
+ * `*.json`, files over the cap, unreadable ones) still cost an open each, and
288
+ * 400k tiny files took about 22 s of CPU. A scan that spent the budget without
289
+ * finding every wanted hash stops and the check fails closed with the mismatch
290
+ * reason. With the floor the scan opens at most 8192 entries and reads about
291
+ * 32 MiB (the last file read may add up to the 1 MiB per-file cap), whichever
292
+ * the entries are. Real reports are a few kilobytes, so the budget covers
293
+ * thousands of them. The scan reads the newest file first, which is where the
294
+ * approved report usually is.
295
+ *
296
+ * This bounds the hash scan only, not the hook as a whole by entry count: the
297
+ * evidence read that follows a refused marker (shared with the no-marker path,
298
+ * `listPersistedReportsBounded`) still opens and parses every `*.json` entry,
299
+ * so a directory of hundreds of thousands of planted entries still takes the
300
+ * hook past the PreToolUse budget, which the runtime treats as an allow. A
301
+ * follow-up task bounds that read.
302
+ */
303
+ export declare const MAX_HASH_SCAN_BYTES: number;
304
+ /**
305
+ * Least a `*.json` entry is charged against {@link MAX_HASH_SCAN_BYTES}
306
+ * whatever the read returned (4 KiB, about one real report), so entries that
307
+ * read nothing or little still spend the budget at a rate that bounds their
308
+ * count: 32 MiB / 4 KiB = 8192 entries.
309
+ */
310
+ export declare const MIN_SCAN_ENTRY_COST_BYTES = 4096;
279
311
  /** Result of {@link walkContainerDepth}. */
280
312
  export interface ContainerDepthWalk {
281
313
  /** True when some array/object nests deeper than the walk's `max` levels. */
@@ -317,6 +349,7 @@ export type ReportFileReadFailure = "unreadable" | "not-regular" | "too-large" |
317
349
  export type BoundedReportRead = {
318
350
  ok: true;
319
351
  raw: string;
352
+ mtimeMs: number;
320
353
  } | {
321
354
  ok: false;
322
355
  reason: ReportFileReadFailure;
@@ -336,8 +369,16 @@ export type BoundedReportRead = {
336
369
  * (never more than the cap plus one byte); a file that grows into that byte
337
370
  * while being read is refused too. The descriptor is always closed. Never
338
371
  * throws.
372
+ *
373
+ * `opts.noFollow` adds `O_NOFOLLOW` to the open: a path that is a symbolic
374
+ * link (whatever it points at, a dangling link included) is refused as
375
+ * not-regular by the open itself (`ELOOP`), with no earlier `lstat` for a
376
+ * swap to race. Off by default, so every hook read keeps following links
377
+ * exactly as before; `harness approve understanding` turns it on.
339
378
  */
340
- export declare function readReportFileBounded(filePath: string): BoundedReportRead;
379
+ export declare function readReportFileBounded(filePath: string, opts?: {
380
+ noFollow?: boolean;
381
+ }): BoundedReportRead;
341
382
  /** Why a report file has no canonical hash. */
342
383
  export type UnhashableReportReason = ReportFileReadFailure | "not-json-object" | "too-deep";
343
384
  export type ReportFileHash = {
@@ -356,9 +397,13 @@ export type ReportFileHash = {
356
397
  * any of these (the `detail` is its message); the gate-read scan counts such
357
398
  * a file as one that matches nothing. Never throws.
358
399
  */
359
- export declare function hashReportFile(filePath: string): ReportFileHash;
400
+ export declare function hashReportFile(filePath: string, opts?: {
401
+ noFollow?: boolean;
402
+ }): ReportFileHash;
360
403
  /** Canonical hash of the report file at `filePath` ({@link hashReportFile}); null when it has none. */
361
- export declare function canonicalReportHashOfFile(filePath: string): string | null;
404
+ export declare function canonicalReportHashOfFile(filePath: string, opts?: {
405
+ noFollow?: boolean;
406
+ }): string | null;
362
407
  /** Marker kind whose signed report hash the gate checks. */
363
408
  export type ApprovalMarkerKind = "task" | "session";
364
409
  /** One matched marker's signed `reportContentHash`, tagged with its kind. */
@@ -411,11 +456,25 @@ export type ApprovedReportHashVerification = {
411
456
  * the "no report file" allow, the same as before this check existed. The
412
457
  * marker and the operator's approval itself are untouched in both cases;
413
458
  * only the audit trail loses its claim to be the approved content. (3) The
414
- * cap bounds each file, not their number: the scan time grows with the
415
- * total volume of report files, so an agent that plants hundreds of
416
- * megabytes of files just under the cap can still push one scan past the
417
- * hook's time budget, which the runtime treats as a non-blocking error (the
418
- * same allow as before this check existed).
459
+ * cap bounds each file, not their number, so the scan charges every `*.json`
460
+ * entry at least `MIN_SCAN_ENTRY_COST_BYTES` against `MAX_HASH_SCAN_BYTES`
461
+ * (bytes read or the floor, whichever is larger, whatever the read returned):
462
+ * past the budget the check denies with the mismatch reason (fail closed).
463
+ * That bounds the entries opened (8192) and the bytes read (about 32 MiB, the
464
+ * last file read may add up to the 1 MiB per-file cap). Three costs remain.
465
+ * A legitimate approval whose report sits behind that much newer report data
466
+ * (by name, descending) is denied; re-approving writes a new, newest report
467
+ * and recovers only when the newer entries are real reports: planted `*.json`
468
+ * names that sort after the timestamped report names (for example names
469
+ * starting with a letter) stay newer than any new report and keep denying
470
+ * until removed. The `readdir` plus name sort of the directory itself are not
471
+ * charged, so a directory of hundreds of thousands of entries still costs that
472
+ * listing before the scan starts. And the hash scan is not the whole hook:
473
+ * the evidence read that follows a refused marker (shared with the no-marker
474
+ * path, `listPersistedReportsBounded`) still opens and parses every `*.json`
475
+ * entry, so the hook as a whole is still unbounded by entry count and a
476
+ * large planted directory runs it past the PreToolUse budget (an allow); a
477
+ * follow-up task bounds that read.
419
478
  *
420
479
  * Removing the approved report while other report files remain is a deny:
421
480
  * nothing on disk carries the signed content any more. `harness gc --apply`
@@ -428,8 +487,8 @@ export type ApprovedReportHashVerification = {
428
487
  * fallback is consulted only when the primary fails the check, so a task
429
488
  * marker signed for another report does not hide a session marker that
430
489
  * verifies. Unchanged behaviour: a null hash on the accepted marker (no
431
- * report resolved, or `--force` over a report the producer could not hash:
432
- * too deeply nested or over the size cap) and a reports directory with no
490
+ * report resolved, or `--force` over a report nested too deeply to hash,
491
+ * never one over the size cap) and a reports directory with no
433
492
  * `*.json` entry at all both allow. A forced approval of a hashable report
434
493
  * signs its hash and is checked like any other.
435
494
  *
@@ -439,3 +498,55 @@ export type ApprovedReportHashVerification = {
439
498
  * value, so nothing in it needs sanitising.
440
499
  */
441
500
  export declare function verifyApprovedReportHash(reportsDir: string, primary: MarkerReportBinding, fallback?: MarkerReportBinding | null): ApprovedReportHashVerification;
501
+ /**
502
+ * Why a `*.json` entry of the reports directory is absent from a bounded
503
+ * listing: it could not be read through {@link readReportFileBounded}.
504
+ */
505
+ export interface SkippedReportEntry {
506
+ filePath: string;
507
+ reason: ReportFileReadFailure;
508
+ detail: string;
509
+ }
510
+ /**
511
+ * {@link listPersistedReports} for every read a PreToolUse hook (or the
512
+ * PostToolUse boundary) makes of the reports directory. The directory holds
513
+ * files the gated agent can write, so every entry is opened once through
514
+ * {@link readReportFileBounded}: non-blocking, typed and sized by `fstat` on
515
+ * the open descriptor, read through it. A `*.json` entry that is not a
516
+ * regular file (a FIFO, a directory, a device, a symlink to one of them), is
517
+ * over `MAX_HASHED_REPORT_BYTES`, cannot be opened or read, or grows while it
518
+ * is read is left out of `reports`, as if it were not there, and recorded in
519
+ * `skipped`: it can neither block the hook nor run it out of memory. There is
520
+ * no separate stat of the path (a symlink flipped to a FIFO between that stat
521
+ * and the read used to hang the read), and the modification time comes from
522
+ * the same descriptor.
523
+ *
524
+ * A caller that must not let the selection fall back to an older report when
525
+ * a newer one is unreadable (the auto-approval precondition) declines when
526
+ * `skipped` is not empty. The operator commands read through this listing as
527
+ * well, so a planted oversized or non-regular entry cannot crash or hang
528
+ * them, and they still see it: `harness approve understanding` refuses
529
+ * while `skipped` is not empty (it passes `refuseSymlinks`, which also
530
+ * refuses a symbolic link at the open, whatever it points at), and
531
+ * `harness gc` reports each skipped entry and leaves it in place.
532
+ *
533
+ * Placement: this block sits at the end of the file, and
534
+ * {@link readPersistedReport} takes the already-read text (`boundedRaw`), so
535
+ * the line numbers the decision record
536
+ * docs/decisions/2026-08-27-ug-auto-mode-approval.md cites in this file stay
537
+ * put (tests/decisions-citations-resolve.test.ts pins them).
538
+ */
539
+ export declare function listPersistedReportsBoundedWithSkips(dir: string, opts?: {
540
+ refuseSymlinks?: boolean;
541
+ }): {
542
+ reports: PersistedReport[];
543
+ skipped: SkippedReportEntry[];
544
+ };
545
+ /**
546
+ * {@link listPersistedReportsBoundedWithSkips} without the skipped entries,
547
+ * for the callers that treat a skipped entry as no evidence
548
+ * ({@link checkPersistedReport}, {@link expirePersistedReport}). Expiry acts
549
+ * on the newest report that was within the cap, so an oversized newest
550
+ * approved report is left as it is and the next in-cap one is expired.
551
+ */
552
+ export declare function listPersistedReportsBounded(dir: string): PersistedReport[];
@@ -65,10 +65,10 @@ function parseFilenameIsoMs(name) {
65
65
  const ms = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
66
66
  return Number.isNaN(ms) ? null : ms;
67
67
  }
68
- function readPersistedReport(filePath, mtimeMs) {
68
+ function readPersistedReport(filePath, mtimeMs, boundedRaw) {
69
69
  let raw;
70
70
  try {
71
- raw = fs.readFileSync(filePath, "utf8");
71
+ raw = boundedRaw ?? fs.readFileSync(filePath, "utf8");
72
72
  }
73
73
  catch {
74
74
  return null;
@@ -95,16 +95,16 @@ function readPersistedReport(filePath, mtimeMs) {
95
95
  }
96
96
  /**
97
97
  * List persisted reports under `dir`, newest-first by creation time
98
- * (JSON `createdAt`, falling back to the filename ISO prefix, falling
99
- * back to mtime). Missing directory returns []. Any I/O error on a
100
- * single file is silently skipped; the caller falls through to the
101
- * ledger result.
98
+ * (JSON `createdAt`, then the filename ISO prefix, then mtime). Missing
99
+ * directory returns []; an I/O error on a single file skips it. Reads each
100
+ * regular file IN FULL, so it must not list the agent-writable reports
101
+ * directory (use {@link listPersistedReportsBoundedWithSkips}); no `src`
102
+ * caller remains, it stays for its tests and the pack's export surface.
102
103
  *
103
104
  * Creation time, NOT mtime, is the sort key: `harness approve
104
105
  * understanding` rewrites the report it flips, which bumps mtime and
105
106
  * made an old just-approved report sort as the freshest
106
- * (harness-discovery C1). mtime survives only as the last-resort
107
- * fallback for files that carry neither timestamp.
107
+ * (harness-discovery C1). mtime is the last-resort fallback.
108
108
  */
109
109
  export function listPersistedReports(dir) {
110
110
  let names;
@@ -296,7 +296,7 @@ export function sanitizeDetailValue(value) {
296
296
  * does not escalate into a session-breaking hook failure.
297
297
  */
298
298
  export function expirePersistedReport(reportsDir, sessionId, now = new Date(), trigger) {
299
- const reports = listPersistedReports(reportsDir);
299
+ const reports = listPersistedReportsBounded(reportsDir);
300
300
  if (reports.length === 0) {
301
301
  return { ok: false, reason: `no reports under ${reportsDir}` };
302
302
  }
@@ -313,13 +313,12 @@ export function expirePersistedReport(reportsDir, sessionId, now = new Date(), t
313
313
  reason: `latest report ${sanitizeDetailValue(path.basename(latest.filePath))} already has approvalStatus=${sanitizeDetailValue(latest.approvalStatus ?? "<missing>")}, nothing to expire`,
314
314
  };
315
315
  }
316
- let raw;
317
- try {
318
- raw = fs.readFileSync(latest.filePath, "utf8");
319
- }
320
- catch (err) {
321
- return { ok: false, reason: `failed to read ${latest.filePath}: ${err.message}` };
316
+ // Bounded like the listing: the file may have been swapped since.
317
+ const read = readReportFileBounded(latest.filePath);
318
+ if (!read.ok) {
319
+ return { ok: false, reason: `failed to read ${latest.filePath}: ${read.detail}` };
322
320
  }
321
+ const raw = read.raw;
323
322
  let parsed;
324
323
  try {
325
324
  parsed = JSON.parse(raw);
@@ -366,7 +365,7 @@ export function expirePersistedReport(reportsDir, sessionId, now = new Date(), t
366
365
  * `understanding-gate approve` CLI, which writes no signed marker).
367
366
  */
368
367
  export function checkPersistedReport(reportsDir, sessionId) {
369
- const reports = listPersistedReports(reportsDir);
368
+ const reports = listPersistedReportsBounded(reportsDir);
370
369
  if (reports.length === 0) {
371
370
  return {
372
371
  claimsApproved: false,
@@ -457,6 +456,38 @@ const MAX_CANONICAL_REPORT_DEPTH = 64;
457
456
  * producers refuse to approve such a report.
458
457
  */
459
458
  export const MAX_HASHED_REPORT_BYTES = 1024 * 1024;
459
+ /**
460
+ * Budget the gate-read hash scan spends before it gives up (32 MiB), charged
461
+ * per `*.json` entry as the larger of the bytes read and
462
+ * {@link MIN_SCAN_ENTRY_COST_BYTES}. The per-file cap bounds one file, not
463
+ * their number: 160 planted 1 MiB key-heavy files took 15.9 to 17.6 s of scan
464
+ * time, past the 15 s PreToolUse budget, and a hook past its budget is a
465
+ * non-blocking error for the runtime (an allow). Charging bytes alone left the
466
+ * entry count open: entries that cost no bytes (tiny files, directories named
467
+ * `*.json`, files over the cap, unreadable ones) still cost an open each, and
468
+ * 400k tiny files took about 22 s of CPU. A scan that spent the budget without
469
+ * finding every wanted hash stops and the check fails closed with the mismatch
470
+ * reason. With the floor the scan opens at most 8192 entries and reads about
471
+ * 32 MiB (the last file read may add up to the 1 MiB per-file cap), whichever
472
+ * the entries are. Real reports are a few kilobytes, so the budget covers
473
+ * thousands of them. The scan reads the newest file first, which is where the
474
+ * approved report usually is.
475
+ *
476
+ * This bounds the hash scan only, not the hook as a whole by entry count: the
477
+ * evidence read that follows a refused marker (shared with the no-marker path,
478
+ * `listPersistedReportsBounded`) still opens and parses every `*.json` entry,
479
+ * so a directory of hundreds of thousands of planted entries still takes the
480
+ * hook past the PreToolUse budget, which the runtime treats as an allow. A
481
+ * follow-up task bounds that read.
482
+ */
483
+ export const MAX_HASH_SCAN_BYTES = 32 * 1024 * 1024;
484
+ /**
485
+ * Least a `*.json` entry is charged against {@link MAX_HASH_SCAN_BYTES}
486
+ * whatever the read returned (4 KiB, about one real report), so entries that
487
+ * read nothing or little still spend the budget at a rate that bounds their
488
+ * count: 32 MiB / 4 KiB = 8192 entries.
489
+ */
490
+ export const MIN_SCAN_ENTRY_COST_BYTES = 4096;
460
491
  /**
461
492
  * Iterative walk over the arrays and objects of `value` (the value itself is
462
493
  * level 1) that stops at the first one nested deeper than `max` levels.
@@ -530,6 +561,12 @@ export function canonicalReportHash(report) {
530
561
  return null;
531
562
  return sha256Hex(JSON.stringify(sortKeysDeep(kept)));
532
563
  }
564
+ /** What `readReportFileBounded` returns for a symbolic link when `noFollow` is set. */
565
+ const SYMLINK_REFUSED = {
566
+ ok: false,
567
+ reason: "not-regular",
568
+ detail: "a symbolic link, not a regular file",
569
+ };
533
570
  function errorCode(err) {
534
571
  const code = err?.code;
535
572
  return typeof code === "string" ? code : String(err);
@@ -548,13 +585,23 @@ function errorCode(err) {
548
585
  * (never more than the cap plus one byte); a file that grows into that byte
549
586
  * while being read is refused too. The descriptor is always closed. Never
550
587
  * throws.
588
+ *
589
+ * `opts.noFollow` adds `O_NOFOLLOW` to the open: a path that is a symbolic
590
+ * link (whatever it points at, a dangling link included) is refused as
591
+ * not-regular by the open itself (`ELOOP`), with no earlier `lstat` for a
592
+ * swap to race. Off by default, so every hook read keeps following links
593
+ * exactly as before; `harness approve understanding` turns it on.
551
594
  */
552
- export function readReportFileBounded(filePath) {
595
+ export function readReportFileBounded(filePath, opts = {}) {
553
596
  let fd;
554
597
  try {
555
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
598
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY |
599
+ fs.constants.O_NONBLOCK |
600
+ (opts.noFollow === true ? fs.constants.O_NOFOLLOW : 0));
556
601
  }
557
602
  catch (err) {
603
+ if (opts.noFollow === true && errorCode(err) === "ELOOP")
604
+ return SYMLINK_REFUSED;
558
605
  return { ok: false, reason: "unreadable", detail: `could not be opened (${errorCode(err)})` };
559
606
  }
560
607
  try {
@@ -578,7 +625,7 @@ export function readReportFileBounded(filePath) {
578
625
  }
579
626
  if (total > stat.size)
580
627
  return { ok: false, reason: "grew", detail: "grew while being read" };
581
- return { ok: true, raw: buf.toString("utf8", 0, total) };
628
+ return { ok: true, raw: buf.toString("utf8", 0, total), mtimeMs: stat.mtimeMs };
582
629
  }
583
630
  catch (err) {
584
631
  return { ok: false, reason: "unreadable", detail: `could not be read (${errorCode(err)})` };
@@ -600,11 +647,15 @@ export function readReportFileBounded(filePath) {
600
647
  * any of these (the `detail` is its message); the gate-read scan counts such
601
648
  * a file as one that matches nothing. Never throws.
602
649
  */
603
- export function hashReportFile(filePath) {
604
- const read = readReportFileBounded(filePath);
650
+ export function hashReportFile(filePath, opts = {}) {
651
+ const read = readReportFileBounded(filePath, opts);
605
652
  if (!read.ok)
606
653
  return read;
607
- const parsed = safeJsonParse(read.raw);
654
+ return hashReportText(read.raw);
655
+ }
656
+ /** {@link hashReportFile} for text already read through {@link readReportFileBounded}. */
657
+ function hashReportText(raw) {
658
+ const parsed = safeJsonParse(raw);
608
659
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
609
660
  return { ok: false, reason: "not-json-object", detail: "not a JSON object" };
610
661
  }
@@ -615,24 +666,31 @@ export function hashReportFile(filePath) {
615
666
  return { ok: true, hash };
616
667
  }
617
668
  /** Canonical hash of the report file at `filePath` ({@link hashReportFile}); null when it has none. */
618
- export function canonicalReportHashOfFile(filePath) {
619
- const hashed = hashReportFile(filePath);
669
+ export function canonicalReportHashOfFile(filePath, opts = {}) {
670
+ const hashed = hashReportFile(filePath, opts);
620
671
  return hashed.ok ? hashed.hash : null;
621
672
  }
622
673
  /**
623
674
  * Hash every `*.json` entry of `dir` (any session, any `approvalStatus`)
624
- * through {@link canonicalReportHashOfFile} and report which of the `wanted`
675
+ * through {@link readReportFileBounded} and report which of the `wanted`
625
676
  * hashes occur. Stops as soon as every wanted hash was found. Every entry
626
677
  * counts as a report file; one that is not a regular file, is larger than
627
678
  * `MAX_HASHED_REPORT_BYTES`, cannot be opened or read, grows while read, is
628
- * not a JSON object, or nests too deeply matches nothing. The cost is linear
629
- * in the number and size of the report files (each read at most up to the
630
- * cap), and the usual allow path scans nearly all of them: the directory
631
- * listing comes back in name order and report names start with a timestamp,
632
- * so the approved (newest) report tends to come last.
679
+ * not a JSON object, or nests too deeply matches nothing. Entries are taken
680
+ * in descending name order, newest first (report names start with a
681
+ * timestamp), so the approved report is usually the first file read. Every
682
+ * entry, whatever its read returned, is charged the larger of the bytes read
683
+ * and `MIN_SCAN_ENTRY_COST_BYTES`; once the charges reach `MAX_HASH_SCAN_BYTES`
684
+ * with a wanted hash still unfound the scan stops and sets `budgetExhausted`,
685
+ * so the number of entries opened and the bytes read (about 32 MiB, the last
686
+ * file read may add up to the per-file cap) are both bounded by the budget,
687
+ * however many entries the directory holds. The `readdir` and the name sort
688
+ * of the directory itself are outside the budget, and so is the evidence read
689
+ * a refused marker falls through to (`listPersistedReportsBounded`), which
690
+ * still opens every `*.json` entry.
633
691
  */
634
692
  function scanReportHashes(dir, wanted) {
635
- const scan = { files: 0, matched: new Set() };
693
+ const scan = { files: 0, matched: new Set(), budgetExhausted: false };
636
694
  let names;
637
695
  try {
638
696
  names = fs.readdirSync(dir);
@@ -640,14 +698,21 @@ function scanReportHashes(dir, wanted) {
640
698
  catch {
641
699
  return scan;
642
700
  }
701
+ names = names.filter((name) => name.endsWith(".json")).sort().reverse();
702
+ let hashedBytes = 0; // budget charged so far, see the charge below
643
703
  for (const name of names) {
644
- if (!name.endsWith(".json"))
645
- continue;
646
- const full = path.join(dir, name);
647
704
  scan.files += 1;
648
- const hash = canonicalReportHashOfFile(full);
649
- if (hash !== null && wanted.has(hash)) {
650
- scan.matched.add(hash);
705
+ if (hashedBytes >= MAX_HASH_SCAN_BYTES) {
706
+ scan.budgetExhausted = true;
707
+ break;
708
+ }
709
+ const read = readReportFileBounded(path.join(dir, name));
710
+ hashedBytes += Math.max(read.ok ? Buffer.byteLength(read.raw) : 0, MIN_SCAN_ENTRY_COST_BYTES);
711
+ if (!read.ok)
712
+ continue;
713
+ const hashed = hashReportText(read.raw);
714
+ if (hashed.ok && wanted.has(hashed.hash)) {
715
+ scan.matched.add(hashed.hash);
651
716
  if (scan.matched.size === wanted.size)
652
717
  break;
653
718
  }
@@ -692,11 +757,25 @@ function scanReportHashes(dir, wanted) {
692
757
  * the "no report file" allow, the same as before this check existed. The
693
758
  * marker and the operator's approval itself are untouched in both cases;
694
759
  * only the audit trail loses its claim to be the approved content. (3) The
695
- * cap bounds each file, not their number: the scan time grows with the
696
- * total volume of report files, so an agent that plants hundreds of
697
- * megabytes of files just under the cap can still push one scan past the
698
- * hook's time budget, which the runtime treats as a non-blocking error (the
699
- * same allow as before this check existed).
760
+ * cap bounds each file, not their number, so the scan charges every `*.json`
761
+ * entry at least `MIN_SCAN_ENTRY_COST_BYTES` against `MAX_HASH_SCAN_BYTES`
762
+ * (bytes read or the floor, whichever is larger, whatever the read returned):
763
+ * past the budget the check denies with the mismatch reason (fail closed).
764
+ * That bounds the entries opened (8192) and the bytes read (about 32 MiB, the
765
+ * last file read may add up to the 1 MiB per-file cap). Three costs remain.
766
+ * A legitimate approval whose report sits behind that much newer report data
767
+ * (by name, descending) is denied; re-approving writes a new, newest report
768
+ * and recovers only when the newer entries are real reports: planted `*.json`
769
+ * names that sort after the timestamped report names (for example names
770
+ * starting with a letter) stay newer than any new report and keep denying
771
+ * until removed. The `readdir` plus name sort of the directory itself are not
772
+ * charged, so a directory of hundreds of thousands of entries still costs that
773
+ * listing before the scan starts. And the hash scan is not the whole hook:
774
+ * the evidence read that follows a refused marker (shared with the no-marker
775
+ * path, `listPersistedReportsBounded`) still opens and parses every `*.json`
776
+ * entry, so the hook as a whole is still unbounded by entry count and a
777
+ * large planted directory runs it past the PreToolUse budget (an allow); a
778
+ * follow-up task bounds that read.
700
779
  *
701
780
  * Removing the approved report while other report files remain is a deny:
702
781
  * nothing on disk carries the signed content any more. `harness gc --apply`
@@ -709,8 +788,8 @@ function scanReportHashes(dir, wanted) {
709
788
  * fallback is consulted only when the primary fails the check, so a task
710
789
  * marker signed for another report does not hide a session marker that
711
790
  * verifies. Unchanged behaviour: a null hash on the accepted marker (no
712
- * report resolved, or `--force` over a report the producer could not hash:
713
- * too deeply nested or over the size cap) and a reports directory with no
791
+ * report resolved, or `--force` over a report nested too deeply to hash,
792
+ * never one over the size cap) and a reports directory with no
714
793
  * `*.json` entry at all both allow. A forced approval of a hashable report
715
794
  * signs its hash and is checked like any other.
716
795
  *
@@ -744,7 +823,75 @@ export function verifyApprovedReportHash(reportsDir, primary, fallback = null) {
744
823
  return {
745
824
  ok: false,
746
825
  detail: `no report in the reports directory matches the content the ${kinds} signed for ` +
747
- `(the approved report was changed or removed after approval); re-run \`harness approve understanding\``,
826
+ `(the approved report was changed or removed after approval); re-run \`harness approve understanding\`` +
827
+ (scan.budgetExhausted
828
+ ? " (the reports directory holds more report data than the gate-read scan budget covers)"
829
+ : ""),
748
830
  };
749
831
  }
832
+ /**
833
+ * {@link listPersistedReports} for every read a PreToolUse hook (or the
834
+ * PostToolUse boundary) makes of the reports directory. The directory holds
835
+ * files the gated agent can write, so every entry is opened once through
836
+ * {@link readReportFileBounded}: non-blocking, typed and sized by `fstat` on
837
+ * the open descriptor, read through it. A `*.json` entry that is not a
838
+ * regular file (a FIFO, a directory, a device, a symlink to one of them), is
839
+ * over `MAX_HASHED_REPORT_BYTES`, cannot be opened or read, or grows while it
840
+ * is read is left out of `reports`, as if it were not there, and recorded in
841
+ * `skipped`: it can neither block the hook nor run it out of memory. There is
842
+ * no separate stat of the path (a symlink flipped to a FIFO between that stat
843
+ * and the read used to hang the read), and the modification time comes from
844
+ * the same descriptor.
845
+ *
846
+ * A caller that must not let the selection fall back to an older report when
847
+ * a newer one is unreadable (the auto-approval precondition) declines when
848
+ * `skipped` is not empty. The operator commands read through this listing as
849
+ * well, so a planted oversized or non-regular entry cannot crash or hang
850
+ * them, and they still see it: `harness approve understanding` refuses
851
+ * while `skipped` is not empty (it passes `refuseSymlinks`, which also
852
+ * refuses a symbolic link at the open, whatever it points at), and
853
+ * `harness gc` reports each skipped entry and leaves it in place.
854
+ *
855
+ * Placement: this block sits at the end of the file, and
856
+ * {@link readPersistedReport} takes the already-read text (`boundedRaw`), so
857
+ * the line numbers the decision record
858
+ * docs/decisions/2026-08-27-ug-auto-mode-approval.md cites in this file stay
859
+ * put (tests/decisions-citations-resolve.test.ts pins them).
860
+ */
861
+ export function listPersistedReportsBoundedWithSkips(dir, opts = {}) {
862
+ let names = [];
863
+ try {
864
+ names = fs.readdirSync(dir);
865
+ }
866
+ catch {
867
+ // No readable directory: no reports.
868
+ }
869
+ const reports = [];
870
+ const skipped = [];
871
+ for (const name of names.filter((n) => n.endsWith(".json"))) {
872
+ const full = path.join(dir, name);
873
+ // `refuseSymlinks` opens with O_NOFOLLOW, so a link is recorded as skipped
874
+ // by the open itself and no earlier lstat can race a swap.
875
+ const read = readReportFileBounded(full, { noFollow: opts.refuseSymlinks === true });
876
+ if (!read.ok) {
877
+ skipped.push({ filePath: full, reason: read.reason, detail: read.detail });
878
+ continue;
879
+ }
880
+ const report = readPersistedReport(full, read.mtimeMs, read.raw);
881
+ if (report !== null)
882
+ reports.push(report);
883
+ }
884
+ reports.sort((a, b) => b.createdAtMs - a.createdAtMs);
885
+ return { reports, skipped };
886
+ }
887
+ /**
888
+ * {@link listPersistedReportsBoundedWithSkips} without the skipped entries,
889
+ * for the callers that treat a skipped entry as no evidence
890
+ * ({@link checkPersistedReport}, {@link expirePersistedReport}). Expiry acts
891
+ * on the newest report that was within the cap, so an oversized newest
892
+ * approved report is left as it is and the next in-cap one is expired.
893
+ */
894
+ export function listPersistedReportsBounded(dir) {
895
+ return listPersistedReportsBoundedWithSkips(dir).reports;
896
+ }
750
897
  //# sourceMappingURL=persisted-reports.js.map