@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.
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +24 -7
- package/dist/cli/apply/install-codex-config.js +353 -81
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +31 -6
- package/dist/cli/approve/understanding.js +197 -56
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/bounded-stdin.d.ts +40 -0
- package/dist/cli/bounded-stdin.js +111 -0
- package/dist/cli/bounded-stdin.js.map +1 -0
- package/dist/cli/doctor/settings-drift.js +12 -2
- package/dist/cli/doctor/settings-drift.js.map +1 -1
- package/dist/cli/gc/index.d.ts +6 -2
- package/dist/cli/gc/index.js +22 -7
- package/dist/cli/gc/index.js.map +1 -1
- package/dist/cli/index.js +15 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pack/auto-approve-path.js +39 -4
- package/dist/cli/pack/auto-approve-path.js.map +1 -1
- package/dist/cli/pack/hook-bootstrap.d.ts +59 -5
- package/dist/cli/pack/hook-bootstrap.js +67 -19
- package/dist/cli/pack/hook-bootstrap.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.d.ts +6 -0
- package/dist/cli/pack/hook-branch-protection.js +11 -2
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +6 -0
- package/dist/cli/pack/hook-codex-pre-tool-use.js +20 -2
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate.d.ts +6 -0
- package/dist/cli/pack/hook-post-merge-gate.js +17 -2
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.d.ts +7 -0
- package/dist/cli/pack/hook-pre-tool-use.js +26 -4
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-runtime-reality.d.ts +2 -0
- package/dist/cli/pack/hook-runtime-reality.js +48 -8
- package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +6 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +11 -2
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.d.ts +6 -0
- package/dist/cli/pack/hook-solution-acceptance.js +11 -2
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/hook-subagent-start.d.ts +2 -0
- package/dist/cli/pack/hook-subagent-start.js +21 -4
- package/dist/cli/pack/hook-subagent-start.js.map +1 -1
- package/dist/cli/policy/intercept.d.ts +2 -0
- package/dist/cli/policy/intercept.js +57 -21
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/record/index.js +97 -21
- package/dist/cli/record/index.js.map +1 -1
- package/dist/cli/session-start/branch-check.js +4 -1
- package/dist/cli/session-start/branch-check.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +0 -15
- package/dist/cli/session-start/index.js +4 -62
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/shared-options.d.ts +15 -4
- package/dist/cli/session-start/shared-options.js +13 -15
- package/dist/cli/session-start/shared-options.js.map +1 -1
- package/dist/cli/session-start/stale-base-check.js +4 -1
- package/dist/cli/session-start/stale-base-check.js.map +1 -1
- package/dist/cli/session-start/toolchain-parity.js +4 -1
- package/dist/cli/session-start/toolchain-parity.js.map +1 -1
- package/dist/io/atomic-write.d.ts +37 -1
- package/dist/io/atomic-write.js +61 -8
- package/dist/io/atomic-write.js.map +1 -1
- package/dist/io/display-path.d.ts +11 -0
- package/dist/io/display-path.js +29 -0
- package/dist/io/display-path.js.map +1 -0
- package/dist/policy-packs/builtin/post-merge-gate.js +5 -2
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +127 -16
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +192 -45
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +3 -2
- package/dist/policy-packs/builtin/understanding-before-execution.js +4 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/runtime/deletion-target-resolve.js +24 -1
- package/dist/runtime/deletion-target-resolve.js.map +1 -1
- package/dist/runtime/destructive-shell-floor.js +15 -1
- package/dist/runtime/destructive-shell-floor.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +26 -0
- package/dist/runtime/shell-word.js +94 -7
- package/dist/runtime/shell-word.js.map +1 -1
- 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":"AAAA,4EAA4E;AAC5E,EAAE;AACF,mEAAmE;AACnE,sEAAsE;AACtE,EAAE;AACF,yEAAyE;AACzE,wEAAwE;AACxE,uEAAuE;AACvE,oEAAoE;AACpE,qEAAqE;AACrE,yEAAyE;AACzE,uEAAuE;AACvE,oEAAoE;AACpE,qEAAqE;AACrE,sEAAsE;AACtE,uEAAuE;AACvE,sEAAsE;AACtE,oEAAoE;AACpE,yEAAyE;AACzE,gEAAgE;AAChE,0EAA0E;AAC1E,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,uEAAuE;AACvE,sEAAsE;AACtE,kEAAkE;AAClE,wEAAwE;AACxE,yDAAyD;AACzD,qEAAqE;AACrE,sEAAsE;AACtE,uEAAuE;AACvE,EAAE;AACF,uEAAuE;AACvE,sEAAsE;AACtE,oEAAoE;AACpE,sEAAsE;AAqBtE,OAAO,EACL,0BAA0B,EAC1B,oBAAoB,EACpB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EAErB,mBAAmB,EAGnB,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAEL,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,
|
|
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`,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
|
432
|
-
*
|
|
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`,
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 =
|
|
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 |
|
|
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
|
-
|
|
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
|
|
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.
|
|
629
|
-
* in
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
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
|
|
713
|
-
*
|
|
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
|