@sabaiway/agent-workflow-kit 4.1.0 → 4.3.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 +90 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +40 -0
- package/references/modes/coverage-check.md +1 -0
- package/references/modes/velocity.md +2 -1
- package/references/shared/command-shapes.md +8 -0
- package/tools/coverage-check.mjs +113 -5
- package/tools/repo-search.mjs +406 -0
- package/tools/run-gates.mjs +91 -6
- package/tools/velocity-profile.mjs +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 4.3.0 — the coverage gate no longer certifies evidence it cannot bind to your tree (AD-081)
|
|
8
|
+
|
|
9
|
+
**Read this if you have ever run `coverage-check --check` on its own.** Until now it read whatever
|
|
10
|
+
LCOV happened to be at the fixed path and gave you a verdict. Nothing tied that file to the tree it
|
|
11
|
+
was judging.
|
|
12
|
+
|
|
13
|
+
**The harmless direction is the one you may have already seen:** you add tests, re-run the checker
|
|
14
|
+
alone, and it prints the *identical* failure list and the *identical* `lcov-sha256` — because the
|
|
15
|
+
LCOV is produced by your `unit-tests` gate and nothing regenerated it.
|
|
16
|
+
|
|
17
|
+
**The same mechanism prints a false PASS,** and that is why this shipped as a fix. Run the suite,
|
|
18
|
+
append one executable line, re-run the checker alone: it certifies "every changed Node line is
|
|
19
|
+
covered". LCOV carries no executability signal, so a line that did not exist when the suite ran has
|
|
20
|
+
no `DA` entry and reads as *non-executable* — nothing to cover.
|
|
21
|
+
|
|
22
|
+
**What changed: a coverage VERDICT is now an outcome of `run-gates --final`, and nowhere else.**
|
|
23
|
+
That run already owns the artifact end to end — it deletes the LCOV before any gate starts, so inside
|
|
24
|
+
it "this came from this tree" is a fact rather than a hope. It now mints a random nonce and records
|
|
25
|
+
the attempt as a one-way commitment over `{nonce, tree fingerprint, base}`; the checker recomputes
|
|
26
|
+
that commitment and refuses to certify without it. The commitment is also the only place your base
|
|
27
|
+
commit is bound, so an identical dirty diff at a moved `HEAD` no longer looks like the same tree.
|
|
28
|
+
|
|
29
|
+
**Three outcomes replace two — and your findings are unchanged:**
|
|
30
|
+
|
|
31
|
+
- inside `--final` → the verdict, exactly as before;
|
|
32
|
+
- anywhere else → `attested=no` and `NO VERDICT`, exit 0, with **every finding still printed**;
|
|
33
|
+
- a context describing a different tree, or matching no recorded attempt → `REFUSED`, exit 1.
|
|
34
|
+
|
|
35
|
+
**Uncovered lines still exit 1 and are still listed `file:line`.** Nothing that was red turns green;
|
|
36
|
+
only the *attestation* is withdrawn where it was never earned — which is why this is a minor release.
|
|
37
|
+
|
|
38
|
+
**Two more places where a green could hide:** the runner no longer trusts the checker's exit status,
|
|
39
|
+
because that code is 0 both when it certifies and when it withholds — it now reads one anchored
|
|
40
|
+
`attested=` line, so **a run that actually consumed an LCOV can no longer mint a green receipt
|
|
41
|
+
without certifying it**. (A run that produced no LCOV at all still records a green receipt with
|
|
42
|
+
`lcovSha256: null` and a loud `skipped-no-lcov`, exactly as before — that path is unchanged.) And a
|
|
43
|
+
plain run prints the withheld verdict aloud instead of leaving a PASS row standing over a claim
|
|
44
|
+
nobody made.
|
|
45
|
+
|
|
46
|
+
**Stated honestly — what this does NOT cover.** Whoever runs both processes can still forge the store
|
|
47
|
+
or the code; that is the same self-discipline posture the receipts have always had. And "the run owns
|
|
48
|
+
the artifact" is exclusive **by convention over a fixed path, not enforced**: if something else writes
|
|
49
|
+
that path while a `--final` is in flight — a second `run-gates`, a hand-run `--only unit-tests`, an
|
|
50
|
+
orphaned test process — the verdict can still land on evidence this run did not produce. Closing that
|
|
51
|
+
needs a per-attempt artifact path, which means your `unit-tests` gate cmd would have to reference
|
|
52
|
+
`$AW_LCOV_FILE`; it is queued rather than rushed into a release, because this is verification code.
|
|
53
|
+
Your base commit is likewise persisted nowhere, so `commit-guard` stays fingerprint-only.
|
|
54
|
+
|
|
55
|
+
**What IS gone** is the false green that needs no second process and nobody trying: evidence that
|
|
56
|
+
simply predates your edit. That is the case that was observed live, and it is pinned by a regression
|
|
57
|
+
test that was watched failing before the fix existed.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## 4.2.0 — a search whose pattern contains `>` no longer has to ask (AD-080)
|
|
61
|
+
|
|
62
|
+
**Read this if you have ever approved `grep -rn "=>" src`.** 4.1.0 explained why that prompt cannot
|
|
63
|
+
be fixed inside the guard. This release stops routing the search through the guard at all.
|
|
64
|
+
|
|
65
|
+
**New: `tools/repo-search.mjs`** — a literal, read-only repository search with two lanes.
|
|
66
|
+
|
|
67
|
+
- `--pattern <literal>` for an ordinary pattern.
|
|
68
|
+
- `--pattern-file <path>` for a pattern containing `>`, `` ` `` or `$(` — the bytes the residual scan
|
|
69
|
+
actually matches (`|` and `&&` do not trip it; they only take a command off the compound
|
|
70
|
+
read-lane). Write the pattern with your host's file-write tool and pass the plain path. **The
|
|
71
|
+
pattern's bytes never enter the command string**, so the scan has nothing to scan. That is not
|
|
72
|
+
obfuscation — encoding would disguise bytes that stay on the shell surface where bash can still run
|
|
73
|
+
them; these leave the surface entirely.
|
|
74
|
+
|
|
75
|
+
**You do not have to remember which lane to use.** The tool's invocation prefix is now in the hook's
|
|
76
|
+
scanned list, so picking the inline lane for a byte-carrying pattern earns a refusal that NAMES
|
|
77
|
+
`--pattern-file`. A wrong choice costs one guiding prompt; it never costs silence.
|
|
78
|
+
|
|
79
|
+
**The hook gained coverage, it did not lose any.** A non-core command never reached the residual scan
|
|
80
|
+
before — that is exactly why a plain kit tool is promptless — so a real redirection or command
|
|
81
|
+
substitution on this tool's own invocation would have gone unexamined. It is examined now, by the
|
|
82
|
+
UNCHANGED scan run over one more prefix. Nothing about what a byte MEANS was re-litigated; AD-079
|
|
83
|
+
stands, and the tool is deliberately NOT in the seeded read-only core, so it inherits no compound
|
|
84
|
+
read-lane allow.
|
|
85
|
+
|
|
86
|
+
**Scope, kept narrow on purpose.** Literal search only — an arbitrary synchronous regex cannot be
|
|
87
|
+
bounded by checks between work units, so the class is removed rather than mitigated. Only regular
|
|
88
|
+
files are read (a FIFO or device read hangs and defeats every bound). Any bound that fires returns a
|
|
89
|
+
structured `incomplete` naming which bound it was — never a silent empty result. The pattern file is
|
|
90
|
+
excluded from its own search by resolved path, so an exotic query cannot match itself.
|
|
91
|
+
|
|
92
|
+
**Honest residual.** Nothing forces the lane on a caller who ignores it: a bare `grep` prompts exactly
|
|
93
|
+
as before. A literal inline `$(` is still indistinguishable from an active one, permanently — use the
|
|
94
|
+
file lane. Bytes in search PATHS still over-ask. And promptlessness rests on your settings honouring
|
|
95
|
+
the tool's allow rule: the hook returning "no decision" is not the same as an allow.
|
|
96
|
+
|
|
7
97
|
## 4.1.0 — why the gate hook still over-asks, established rather than assumed (AD-079)
|
|
8
98
|
|
|
9
99
|
**Read this if the hook has ever made you approve `grep -rn "=>" src` or a plain read wearing
|
package/README.md
CHANGED
|
@@ -239,7 +239,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
239
239
|
| `/agent-workflow-kit sandbox-masks` | any time | **cosmetic exclude lane for sandbox device masks** — an OS sandbox (Claude Code) injects character-device masks into the work tree as untracked `git status` noise; the review domain already ignores them **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat — never a frozen list — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
|
|
240
240
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
|
|
241
241
|
| `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
242
|
-
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
242
|
+
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — **certifies coverage ONLY inside the `--final` run that owns the lcov** (ownership is exclusive by CONVENTION over the fixed path, not enforced — a concurrent writer to it is a stated residual, queued as LCOV-EXCLUSIVE-OWNERSHIP): an artifact on disk proves nothing about the tree it came from, so a standalone run prints its findings and states `attested=no` / `NO VERDICT` rather than a PASS (an lcov that predates an edit would otherwise certify a line the suite never executed). The runner passes a nonce whose one-way commitment over `{nonce, fingerprint, base}` is the `final-start.attempt` it recorded; a context describing another tree, or matching no recorded attempt, is a REFUSAL, never a verdict. Findings are unchanged — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
243
243
|
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
244
244
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile follow-up) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
245
245
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.3.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -261,6 +261,33 @@ export const matchSeededCorePrefix = (command) => {
|
|
|
261
261
|
return matched ? matched.join(' ') : null;
|
|
262
262
|
};
|
|
263
263
|
|
|
264
|
+
// Kit tools whose invocations rung (b) scans even though they are NOT in the seeded read-only core.
|
|
265
|
+
// The distinction is deliberate and load-bearing: CORING one of these would also hand it the
|
|
266
|
+
// rung (c) read-lane allow, which is exactly what a tool taking caller-supplied arguments must not
|
|
267
|
+
// have. This SEPARATE list buys the residual coverage without that side effect.
|
|
268
|
+
//
|
|
269
|
+
// AD-079 line: this is the SAME unchanged scan run over a different prefix — it never asks what a
|
|
270
|
+
// byte MEANS, so it is on the permitted side. Nothing here parses or deletes a span.
|
|
271
|
+
// KIT-QUALIFIED, forward-slash canonical. Matching on the bare basename was the first attempt and
|
|
272
|
+
// review killed it: it would pull ANY unrelated `repo-search.mjs` into rung (b2), turning a
|
|
273
|
+
// pre-existing NO decision into an ASK — a change to a decision path this release has no business
|
|
274
|
+
// touching.
|
|
275
|
+
export const SCANNED_TOOL_PATHS = Object.freeze(['agent-workflow-kit/tools/repo-search.mjs']);
|
|
276
|
+
|
|
277
|
+
// Quotes stripped and separators canonicalised, so a relative, absolute, quoted or Windows-separated
|
|
278
|
+
// spelling all compare the same. SUBSTRING, not equality, and scanned across EVERY token rather than
|
|
279
|
+
// just the one after `node`: both reviewers found the strict form under-inclusive, one through node
|
|
280
|
+
// flags (`node --no-warnings <tool>`), one through an operator attached to the path
|
|
281
|
+
// (`node <tool>>out`). A missed invocation silently restores the unscanned behaviour on exactly the
|
|
282
|
+
// surface this exists to cover, while a spurious match merely over-asks — so inclusiveness wins.
|
|
283
|
+
const canonicalToken = (token) => token.replace(/["']/gu, '').replace(/\\/gu, '/');
|
|
284
|
+
|
|
285
|
+
export const matchScannedToolPrefix = (command) => {
|
|
286
|
+
const tokens = tokenizeCommand(command).map(canonicalToken);
|
|
287
|
+
const hit = SCANNED_TOOL_PATHS.find((path) => tokens.some((token) => token.includes(path)));
|
|
288
|
+
return hit ?? null;
|
|
289
|
+
};
|
|
290
|
+
|
|
264
291
|
// String-level, conservative: the hook sees the PRE-SHELL command string, so every class is a raw
|
|
265
292
|
// substring scan (never a whitespace-token check — a token check misses `"--output=f"` / `'>' f`
|
|
266
293
|
// where the quotes are still in the string but the shell will strip them). A quoted metacharacter
|
|
@@ -380,6 +407,19 @@ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gate
|
|
|
380
407
|
};
|
|
381
408
|
}
|
|
382
409
|
}
|
|
410
|
+
// (b2) the same guard over the scanned kit tools. The refusal NAMES the lane that avoids it, so a
|
|
411
|
+
// caller who picked the inline lane for a shell-significant pattern is corrected by the mechanism
|
|
412
|
+
// rather than expected to have remembered the rule.
|
|
413
|
+
const scannedTool = matchScannedToolPrefix(trimmed);
|
|
414
|
+
if (scannedTool !== null) {
|
|
415
|
+
const residualClasses = detectResidualClasses(trimmed);
|
|
416
|
+
if (residualClasses.length > 0) {
|
|
417
|
+
return {
|
|
418
|
+
permissionDecision: DECISION_ASK,
|
|
419
|
+
permissionDecisionReason: `agent-workflow residual guard: "${scannedTool}" carries ${residualClasses.join(' + ')} — pass the pattern with --pattern-file (its bytes then never enter the command string), or confirm by hand if the byte is really part of the invocation`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
383
423
|
// (c) read-lane allow — opt-in (lanes.json), mode-fenced like (a) but cwd-agnostic (a read is a
|
|
384
424
|
// read from any directory). Runs AFTER (b), so a residual-carrying core command still ASKs.
|
|
385
425
|
if (readLaneOn === true && ALLOW_PERMISSION_MODES.includes(permissionMode) && isReadLaneCommand(trimmed)) {
|
|
@@ -6,6 +6,7 @@ The **final-run checker** (strip-the-kit D3(c)+(d)) — two deterministic arms o
|
|
|
6
6
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/coverage-check.mjs --check [--cwd <dir>]`:
|
|
8
8
|
|
|
9
|
+
0. **Attestation precondition (the provenance arm).** An lcov on disk carries no evidence of the tree it came from, so reading one and issuing a verdict certifies whatever happens to be there — the false GREEN direction is the dangerous one, because a line appended AFTER the suite ran has no `DA` entry and therefore reads non-executable ("nothing to cover"). Provenance is a CONSEQUENCE in exactly one context: a `run-gates --final` run deletes the artifact before any gate spawns. That runner mints a random nonce and writes `final-start.attempt` as a ONE-WAY COMMITMENT over `{nonce, tree fingerprint, base}`; the raw nonce rides the environment to this child, which recomputes the commitment and requires the record to carry it. Neither half suffices alone — a bare nonce is unverifiable, a persisted attempt id is reconstructible from public repo state — and the commitment is also the only place the BASE is bound, since no record stores it. The raw nonce is stripped from the red-proof probe environment so no descendant retains a live capability. Outcomes: **attested** → the coverage verdict is issued; **no handshake** → `attested=no` + `NO VERDICT` (exit 0, findings still printed, uncovered lines still exit 1 — the findings contract is unchanged); **a handshake describing another tree or matching no recorded attempt** → `REFUSED` (exit 1), never a verdict in either direction. One fully anchored `coverage-check: attested=<yes|no>` machine line rides every run, on the same exactly-once contract as the sha line. Stated residuals, both named rather than implied: (a) an operator who runs both processes can forge the store or the code — the kit's standing self-discipline posture, not a security boundary; (b) **"the run owns the artifact" is exclusive by CONVENTION over the fixed path, not enforced** — a writer outside the run (a second `run-gates`, a hand-run `--only unit-tests`, an orphaned test process) can place foreign evidence between the deletion and the checker's read, and every check then agrees. Closing (b) needs an attempt-unique artifact path, which the runner can name but the declared producer cmd must reference — queued as LCOV-EXCLUSIVE-OWNERSHIP. What this arm removes is the false green that needs no second process and nobody trying: evidence that predates the edit.
|
|
9
10
|
1. **Coverage arm (D3(d)):** every CHANGED executable Node line (`.mjs`/`.cjs`/`.js`, tracked working-vs-HEAD changes + untracked-not-ignored files) must be covered — uncovered lines are LISTED `file:line` and fail; a changed file ABSENT from the lcov map is a file-level red (never "non-executable" by silence); changed out-of-domain files (e.g. `.sh`) and unsupported-source files (e.g. `.ts`) are LISTED — the claim is narrowed honestly, not widened. NO lcov file at the path = a LOUD `skipped-no-lcov` (exit 0, stated — produce the file via the unit-tests gate's lcov reporters); a symlink at the path is a refusal (lstat, no-follow).
|
|
10
11
|
2. **Red-proof arm (D3(c)):** every authoritative current-base `red-proof` declaration must verify — the bound test file exists (deleted fails), its content sha256 matches the declaration (custody), the test resolves (zero-match fails) and runs green N/N NOW, and the declaration's pre-fix fingerprint differs from the current tree (equal = reuse/forgery, refused). A malformed evidence store fails CLOSED.
|
|
11
12
|
|
|
@@ -33,10 +33,11 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
|
|
|
33
33
|
- `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd ${PROJECT_ROOT}` (wildcard — the read-only deployment advisor; its rendered apply one-liners are writers and still prompt)
|
|
34
34
|
- `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs --strict <skill-dir>` (wildcard)
|
|
35
35
|
- `node ${CLAUDE_SKILL_DIR}/tools/release-scan.mjs <path>` (wildcard)
|
|
36
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead, so the bytes never enter the command string. This is the ONE tier tool that DOES carry hook residual coverage — see the honesty note below)
|
|
36
37
|
- `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--final`** forms all still prompt (`--final` WRITES the final-run receipt into the core-evidence store — a recording run is never auto-approved).
|
|
37
38
|
- Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
|
|
38
39
|
|
|
39
|
-
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage
|
|
40
|
+
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with ONE deliberate exception, `repo-search.mjs`**, whose invocation prefix is in the hook's scanned list precisely because it takes caller-supplied argument bytes: a real redirection or command substitution on ITS invocation raises the ask, and the refusal names the `--pattern-file` lane that avoids it. It is NOT in the seeded core, so it never inherits the read-lane compound allow. Every other tier entry: the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
|
|
40
41
|
|
|
41
42
|
**Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
|
|
42
43
|
|
|
@@ -16,6 +16,14 @@ and improvised shapes are where approval prompts come from. The bar:
|
|
|
16
16
|
promptless by construction.
|
|
17
17
|
- **Improvised file writes ride the host's file-edit tools** (Write/Edit or the equivalent) —
|
|
18
18
|
never an ad-hoc heredoc or shell-redirect write.
|
|
19
|
+
- **Searching for TEXT is its own case.** A pattern carrying `>`, `` ` `` or `$(` prompts on a
|
|
20
|
+
seeded-core command however it is quoted — the guard scans the raw string and a quote-stripped
|
|
21
|
+
copy. Quoting is not a workaround. (`|`/`&&` do not trip it.) Use the host's search tool if it has
|
|
22
|
+
one; else, where the kit tier is seeded, `node <kit>/tools/repo-search.mjs --pattern <literal>`,
|
|
23
|
+
switching to `--pattern-file <path>` for a byte-carrying pattern — written with the file-write tool
|
|
24
|
+
above, so its bytes never enter the command string; else one plain command, accepting the prompt.
|
|
25
|
+
A wrong lane earns a refusal that NAMES the file lane. Residual: a bare `grep` still prompts, and
|
|
26
|
+
the file lane needs promptless host writes.
|
|
19
27
|
|
|
20
28
|
**Scope — improvised shapes only.** The executable commands a mode doc itself prescribes (the
|
|
21
29
|
`node …/tools/…` dispatch lines, `--apply` lanes, install/symlink steps) are OUTSIDE this
|
package/tools/coverage-check.mjs
CHANGED
|
@@ -117,6 +117,68 @@ export const keyFor = (rootTop, rel) => {
|
|
|
117
117
|
}
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
+
// ── the attestation handshake (the provenance precondition) ───────────────────────────────────────
|
|
121
|
+
// An LCOV on disk carries NO evidence of the tree it was produced from, so reading one and issuing
|
|
122
|
+
// a verdict certifies whatever happens to be there. The STALE-FAILURE direction was observed live
|
|
123
|
+
// (2026-07-27, an identical failure list and sha after tests were added); the FALSE GREEN — the
|
|
124
|
+
// dangerous one — was reproduced HERMETICALLY: a line appended after the suite ran has no DA entry,
|
|
125
|
+
// and lcov.mjs:9-13 states that reads as non-executable, i.e. "nothing to cover".
|
|
126
|
+
//
|
|
127
|
+
// Provenance is a CONSEQUENCE inside exactly one context: a `run-gates --final` run deletes the
|
|
128
|
+
// artifact before any gate spawns, so anything present came from that run. The runner mints a random
|
|
129
|
+
// nonce and writes `final-start.attempt` as a one-way COMMITMENT over {nonce, fingerprint, base};
|
|
130
|
+
// the raw nonce rides the environment to this child, which recomputes the commitment and requires
|
|
131
|
+
// the record to carry it. Neither half suffices alone: a bare nonce is unverifiable (any later value
|
|
132
|
+
// would do), a persisted attempt id alone is replayable (it is reconstructible from public repo
|
|
133
|
+
// state, which is how the first two designs certified foreign evidence). The commitment also BINDS
|
|
134
|
+
// THE BASE, which is persisted nowhere else — the only way a base check is possible at all.
|
|
135
|
+
//
|
|
136
|
+
// Residual, stated: an operator who runs both processes can forge the store or the code. That is the
|
|
137
|
+
// kit's standing posture (review-state.mjs HUMAN residual), not a new weakening. What this removes
|
|
138
|
+
// is the ACCIDENTAL false green — an interrupted run plus an ordinary later test run, nobody trying.
|
|
139
|
+
export const ATTEST_NONCE_ENV = 'AW_FINAL_ATTEST_NONCE';
|
|
140
|
+
export const ATTEST_FINGERPRINT_ENV = 'AW_FINAL_ATTEST_FINGERPRINT';
|
|
141
|
+
export const ATTEST_BASE_ENV = 'AW_FINAL_ATTEST_BASE';
|
|
142
|
+
const ATTEST_ENV_VARS = Object.freeze([ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]);
|
|
143
|
+
|
|
144
|
+
// The commitment bytes: newline-joined, which is unambiguous because every field is hex or empty
|
|
145
|
+
// (the nonce and fingerprint by construction, the base a git object id or '' on an unborn branch).
|
|
146
|
+
export const commitmentFor = (nonce, fingerprint, base) =>
|
|
147
|
+
createHash('sha256').update(`${nonce}\n${fingerprint}\n${base ?? ''}`).digest('hex');
|
|
148
|
+
|
|
149
|
+
// A child of this process must never inherit a LIVE capability: the red-proof arm spawns `node --test`
|
|
150
|
+
// probes, and a detached descendant holding the raw nonce could attest later.
|
|
151
|
+
export const withoutAttestEnv = (env) => {
|
|
152
|
+
const out = { ...env };
|
|
153
|
+
for (const key of ATTEST_ENV_VARS) delete out[key];
|
|
154
|
+
return out;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// → { attesting: true } | { attesting: false, reason } | { refusal } — a refusal is an exit-1
|
|
158
|
+
// identity failure (the runner handed a context that no longer describes this tree), DISTINCT from
|
|
159
|
+
// the ordinary non-attesting case, which is exit 0 and merely withholds the verdict.
|
|
160
|
+
// Exported as a test seam (the keyFor idiom): the undecidable-identity arm below cannot be reached
|
|
161
|
+
// through the CLI, where a resolvable work tree is a precondition of getting this far.
|
|
162
|
+
export const attestationState = ({ env, records, fingerprint, base }) => {
|
|
163
|
+
const nonce = env[ATTEST_NONCE_ENV];
|
|
164
|
+
if (typeof nonce !== 'string' || nonce === '') {
|
|
165
|
+
return { attesting: false, reason: 'no final-run attestation context — a verdict is issued only inside the run that owns the lcov (run-gates.mjs --final); findings below are informational' };
|
|
166
|
+
}
|
|
167
|
+
const passedFingerprint = env[ATTEST_FINGERPRINT_ENV] ?? '';
|
|
168
|
+
const passedBase = env[ATTEST_BASE_ENV] ?? '';
|
|
169
|
+
if (fingerprint == null) {
|
|
170
|
+
return { refusal: 'the attestation context cannot be verified — this tree has no computable fingerprint' };
|
|
171
|
+
}
|
|
172
|
+
if (passedFingerprint !== fingerprint || passedBase !== (base ?? '')) {
|
|
173
|
+
return { refusal: `the tree MOVED under the final run (the attestation context describes ${passedFingerprint.slice(0, 12)}…@${passedBase.slice(0, 12) || 'unborn'}, this tree is ${fingerprint.slice(0, 12)}…@${(base ?? '').slice(0, 12) || 'unborn'}) — a gate changed the working tree after the suite produced the lcov; re-run run-gates.mjs --final` };
|
|
174
|
+
}
|
|
175
|
+
const want = commitmentFor(nonce, fingerprint, base ?? '');
|
|
176
|
+
if (!records.some((r) => r.kind === 'final-start' && r.attempt === want)) {
|
|
177
|
+
return { refusal: 'the attestation context matches no recorded final-run attempt — the evidence store lost or never received the start record; re-run run-gates.mjs --final' };
|
|
178
|
+
}
|
|
179
|
+
return { attesting: true };
|
|
180
|
+
};
|
|
181
|
+
|
|
120
182
|
// ── the red-proof verification arm (D3(c)) ────────────────────────────────────────────────────────
|
|
121
183
|
|
|
122
184
|
// verifyRedProofs({ rootTop, cwd, env }) → { failures: [...], verified: n } | { storeFailure }.
|
|
@@ -175,6 +237,12 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
175
237
|
const lcovPath = resolveLcovPath(cwd, env);
|
|
176
238
|
const lines = [];
|
|
177
239
|
let failed = false;
|
|
240
|
+
// The identity is read BEFORE the coverage arm and again AFTER it: a single up-front comparison
|
|
241
|
+
// leaves a window in which the tree moves between the surface walk and the verdict.
|
|
242
|
+
const identityBefore = { fingerprint: computeTreeFingerprint(cwd), base: resolveBase(cwd) };
|
|
243
|
+
const storePath = resolveEvidencePath(cwd, env);
|
|
244
|
+
const storeRecords = storePath ? readEvidence(storePath).records : [];
|
|
245
|
+
const attestBefore = attestationState({ env, records: storeRecords, ...identityBefore });
|
|
178
246
|
const cov = checkCoverage({ rootTop, lcovPath });
|
|
179
247
|
// The machine line the final-run receipt binds (M2): the sha of the exact bytes THIS check
|
|
180
248
|
// consumed — `none` states loudly that no lcov was read.
|
|
@@ -189,7 +257,8 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
189
257
|
for (const f of cov.failures) lines.push(` ${f}`);
|
|
190
258
|
}
|
|
191
259
|
}
|
|
192
|
-
|
|
260
|
+
// The probes must never inherit a live attestation capability (a detached descendant could keep it).
|
|
261
|
+
const red = verifyRedProofs({ rootTop, cwd, env: withoutAttestEnv(env) });
|
|
193
262
|
if (red.storeFailure) {
|
|
194
263
|
failed = true;
|
|
195
264
|
lines.push(`coverage-check: FAIL — ${red.storeFailure}`);
|
|
@@ -202,7 +271,24 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
202
271
|
lines.push(`coverage-check: ${red.verified} red-proof record(s) verified green N/N with custody intact`);
|
|
203
272
|
}
|
|
204
273
|
}
|
|
205
|
-
|
|
274
|
+
// Re-read the identity AFTER the coverage arm and re-decide: the attestation must describe the
|
|
275
|
+
// tree the verdict was actually computed over, not the one it started over.
|
|
276
|
+
const identityAfter = { fingerprint: computeTreeFingerprint(cwd), base: resolveBase(cwd) };
|
|
277
|
+
const attestAfter = attestationState({ env, records: storeRecords, ...identityAfter });
|
|
278
|
+
const attestation = attestBefore.refusal ? attestBefore : attestAfter;
|
|
279
|
+
const attesting = attestation.attesting === true && attestBefore.attesting === true;
|
|
280
|
+
// EXACTLY ONE fully anchored machine line, the lcov-sha256 contract's sibling — the runner binds
|
|
281
|
+
// it, so a missing/duplicated/injected one is an integrity failure rather than a silent green.
|
|
282
|
+
lines.push(`coverage-check: attested=${attesting ? 'yes' : 'no'}`);
|
|
283
|
+
if (attestation.refusal) {
|
|
284
|
+
failed = true;
|
|
285
|
+
lines.push(`coverage-check: REFUSED — ${attestation.refusal}`);
|
|
286
|
+
} else if (!attesting) {
|
|
287
|
+
lines.push(`coverage-check: NO VERDICT — ${attestation.reason}`);
|
|
288
|
+
}
|
|
289
|
+
// The attestation gates ONLY the coverage claim. Every pre-existing fail-closed refusal above
|
|
290
|
+
// (symlinked lcov, malformed evidence store, unmet red-proof obligation) keeps its own exit 1.
|
|
291
|
+
if (attesting && !failed && !cov.skipped && cov.failures.length === 0) {
|
|
206
292
|
lines.push('coverage-check: PASS — every changed Node line is covered');
|
|
207
293
|
}
|
|
208
294
|
return { code: failed ? 1 : 0, lines };
|
|
@@ -225,9 +311,21 @@ An absent lcov file is a LOUD skipped-no-lcov (exit 0 — NO coverage check ran,
|
|
|
225
311
|
symlinked lcov path, an uncovered line, a broken red-proof obligation, or a malformed evidence
|
|
226
312
|
store fails (exit 1).
|
|
227
313
|
|
|
314
|
+
A coverage VERDICT is issued ONLY inside the run that owns the artifact's lifetime: run-gates
|
|
315
|
+
--final deletes the lcov before any gate spawns and hands this checker an attestation context
|
|
316
|
+
(a nonce whose one-way commitment over {nonce, fingerprint, base} is the final-start attempt id).
|
|
317
|
+
One anchored machine line rides every run: coverage-check: attested=<yes|no>.
|
|
318
|
+
attested=yes → the verdict, exactly as before.
|
|
319
|
+
attested=no → NO VERDICT (exit 0): findings are still printed, uncovered lines still exit 1;
|
|
320
|
+
only the PASS attestation is withheld.
|
|
321
|
+
REFUSED (exit 1) → the context describes another tree, or matches no recorded attempt.
|
|
322
|
+
Residual, stated: ownership of the fixed path is CONVENTION, not enforcement — a concurrent writer
|
|
323
|
+
to it can still place foreign evidence (queued as LCOV-EXCLUSIVE-OWNERSHIP).
|
|
324
|
+
|
|
228
325
|
Sandbox-safe: no network; writes nothing; spawns read-only git queries and the bound-test
|
|
229
|
-
probes (node --test, shell-free) — the D4 sandbox lane.
|
|
230
|
-
|
|
326
|
+
probes (node --test, shell-free) — the D4 sandbox lane. The attestation variables are consumed and
|
|
327
|
+
removed from this process's environment before anything spawns, so no child inherits the capability.
|
|
328
|
+
Read-only. Exit codes: 0 pass / no-verdict / skipped-loud; 1 fail or REFUSED; 2 usage.`;
|
|
231
329
|
|
|
232
330
|
export const main = (argv, ctx = {}) => {
|
|
233
331
|
const env = ctx.env ?? process.env;
|
|
@@ -253,7 +351,17 @@ export const main = (argv, ctx = {}) => {
|
|
|
253
351
|
|
|
254
352
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
255
353
|
if (isDirectRun) {
|
|
256
|
-
|
|
354
|
+
// The capability is CONSUMED here: snapshot it, then remove it from this process's environment
|
|
355
|
+
// before anything spawns. Every `git` query and every bound-test probe below inherits
|
|
356
|
+
// process.env, so leaving it in place would hand a live attestation context to each of them —
|
|
357
|
+
// and a detached descendant could then certify a foreign lcov long after this run ended.
|
|
358
|
+
const attest = Object.fromEntries(
|
|
359
|
+
[ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]
|
|
360
|
+
.filter((k) => process.env[k] !== undefined)
|
|
361
|
+
.map((k) => [k, process.env[k]]),
|
|
362
|
+
);
|
|
363
|
+
for (const k of Object.keys(attest)) delete process.env[k];
|
|
364
|
+
const r = main(process.argv.slice(2), { env: { ...process.env, ...attest } });
|
|
257
365
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
258
366
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
259
367
|
process.exitCode = r.code;
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// repo-search.mjs — the promptless repository search lane (LITERAL search, read-only).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. A search whose pattern carries a shell-significant byte (`>`, `` ` ``, `$(`)
|
|
5
|
+
// cannot be issued as a seeded-core command without raising the residual ASK: the guard scans the
|
|
6
|
+
// raw command string AND a quote-stripped copy, so no quoting protects the byte. That is not a bug
|
|
7
|
+
// to fix in the guard — AD-079 closed that direction on four verified counterexamples. This tool
|
|
8
|
+
// routes around it instead, in two lanes:
|
|
9
|
+
//
|
|
10
|
+
// lane 1 --pattern <literal> for a pattern with no shell-significant byte
|
|
11
|
+
// lane 2 --pattern-file <path> the pattern's bytes NEVER enter the command string
|
|
12
|
+
//
|
|
13
|
+
// The selection rule is enforced by the hook, not by memory: this tool's invocation is in the
|
|
14
|
+
// hook's scanned list, so choosing lane 1 for a byte-carrying pattern earns an ASK whose reason
|
|
15
|
+
// names lane 2. A wrong choice costs one guiding prompt; it never costs silence.
|
|
16
|
+
//
|
|
17
|
+
// CONTRACT
|
|
18
|
+
// LITERAL only — no regex dialect, and none is planned for this slice: a bounded walk cannot
|
|
19
|
+
// interrupt a catastrophically backtracking RegExp call, so the class is removed, not mitigated.
|
|
20
|
+
// Multiline patterns DO match (the search runs over the whole decoded buffer; a hit reports the
|
|
21
|
+
// line it starts on).
|
|
22
|
+
// Four outcomes, never collapsed: matches (0), no matches (0 with an explicitly empty result),
|
|
23
|
+
// INCOMPLETE (3, naming the bound that fired), invalid input (2), I/O failure (1).
|
|
24
|
+
// CONTAINMENT is decided on the REAL path, never lexically — a symlinked ancestor resolves out of
|
|
25
|
+
// the root while passing every `..` check, and on Windows `relative()` across drives returns an
|
|
26
|
+
// absolute path that contains no `..` at all.
|
|
27
|
+
// Every file is opened NO-FOLLOW and NON-BLOCKING, then `fstat`-ed on the descriptor actually
|
|
28
|
+
// opened: an lstat-then-read pair loses to a swap between the two calls, and the swapped-in FIFO
|
|
29
|
+
// is precisely the blocking read this tool promises cannot happen.
|
|
30
|
+
// Directories are walked INCREMENTALLY with the budget checked before each entry — reading and
|
|
31
|
+
// sorting a whole directory first is unbounded work in exactly the case bounds exist for.
|
|
32
|
+
//
|
|
33
|
+
// THREAT MODEL, stated rather than implied. Explicit `--path`/`--pattern-file` targets are
|
|
34
|
+
// resolved (so a symlink INSIDE the root is followed by design) and then containment-checked on
|
|
35
|
+
// the real path; the WALK never traverses a symlink at all. What is NOT defended against is an
|
|
36
|
+
// adversary mutating the tree DURING the walk: a directory swapped for a symlink between its
|
|
37
|
+
// lstat and its opendir would be traversed, and closing that needs descriptor-relative traversal
|
|
38
|
+
// (`openat` semantics) which dependency-free Node does not expose. This tool searches a workspace
|
|
39
|
+
// its own agent controls; concurrent hostile mutation is out of scope, and saying so is the
|
|
40
|
+
// honest close — an unstated residual would be the defect.
|
|
41
|
+
// Pure reader — no writes, no subprocess, no network. Dependency-free, Node >= 22, no side
|
|
42
|
+
// effects on import (the isDirectRun idiom).
|
|
43
|
+
|
|
44
|
+
import { openSync, fstatSync, readSync, closeSync, opendirSync, realpathSync, lstatSync, constants } from 'node:fs';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
import { join, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
47
|
+
import { pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
export const EXIT_OK = 0;
|
|
50
|
+
export const EXIT_ERROR = 1;
|
|
51
|
+
export const EXIT_USAGE = 2;
|
|
52
|
+
export const EXIT_INCOMPLETE = 3;
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_MAX_RESULTS = 200;
|
|
55
|
+
export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
56
|
+
export const DEFAULT_WALK_BUDGET = 20000;
|
|
57
|
+
// Hard ceilings: a caller-supplied bound may lower these, never raise them. Without a ceiling the
|
|
58
|
+
// bounds are advisory, which is the same as absent.
|
|
59
|
+
export const HARD_MAX_RESULTS = 100000;
|
|
60
|
+
export const HARD_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
62
|
+
// Characters of context kept on EACH side of a match, and a HARD ceiling on the whole snippet.
|
|
63
|
+
// The ceiling is the load-bearing one: bounding only the context still lets a huge --pattern-file
|
|
64
|
+
// matched in many places accumulate, because the match itself rode into every snippet. With a total
|
|
65
|
+
// cap the stored size per match is a constant, so no size of pattern or file can grow it.
|
|
66
|
+
const SNIPPET_CONTEXT = 200;
|
|
67
|
+
const SNIPPET_MAX = 512;
|
|
68
|
+
const NEVER_WALKED = Object.freeze(['.git', 'node_modules']);
|
|
69
|
+
// O_NOFOLLOW refuses a symlinked leaf at open time; O_NONBLOCK means a FIFO that slipped in returns
|
|
70
|
+
// instead of hanging. Both are POSIX; on a platform lacking them the flags degrade to 0 and the
|
|
71
|
+
// fstat check below is the remaining guard.
|
|
72
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
73
|
+
const NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
74
|
+
const OPEN_FLAGS = constants.O_RDONLY | NOFOLLOW | NONBLOCK;
|
|
75
|
+
|
|
76
|
+
class UsageError extends Error {}
|
|
77
|
+
class IoError extends Error {}
|
|
78
|
+
|
|
79
|
+
// The pattern is echoed as a DIGEST plus a byte length — never as a first content line. A first
|
|
80
|
+
// line cannot separate two multiline patterns that share it, and it is unsafe for NUL/control
|
|
81
|
+
// bytes; a digest separates them and survives any byte.
|
|
82
|
+
export const patternDigest = (pattern) => {
|
|
83
|
+
const buf = Buffer.from(pattern, 'utf8');
|
|
84
|
+
return { digest: createHash('sha256').update(buf).digest('hex').slice(0, 16), bytes: buf.length };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Exactly ONE trailing line ending is stripped — CRLF as one unit: a pattern file written by an
|
|
88
|
+
// editor almost always ends in one, while a pattern that deliberately ends in a blank line keeps it.
|
|
89
|
+
// Leaving a stray `\r` would make the search silently fail against LF content, which is the worst
|
|
90
|
+
// possible failure mode for a tool whose whole job is finding text.
|
|
91
|
+
export const resolvePattern = (raw) => {
|
|
92
|
+
if (raw.endsWith('\r\n')) return raw.slice(0, -2);
|
|
93
|
+
return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// `Number()` turns a long digit string into Infinity, which would disable the very bound being
|
|
97
|
+
// parsed. Safe-integer and a hard ceiling are both required.
|
|
98
|
+
export const parseCount = (raw, flag, ceiling) => {
|
|
99
|
+
if (!/^\d{1,15}$/u.test(raw ?? '')) throw new UsageError(`${flag} needs a plain non-negative integer, got: ${raw ?? '(missing)'}`);
|
|
100
|
+
const n = Number(raw);
|
|
101
|
+
if (!Number.isSafeInteger(n)) throw new UsageError(`${flag} is not a safe integer: ${raw}`);
|
|
102
|
+
if (n > ceiling) throw new UsageError(`${flag} exceeds the hard ceiling ${ceiling}: ${raw}`);
|
|
103
|
+
return n;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const parseArgs = (argv) => {
|
|
107
|
+
const opts = { pattern: null, patternFile: null, paths: [], max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, json: false };
|
|
108
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
109
|
+
const arg = argv[i];
|
|
110
|
+
const next = () => {
|
|
111
|
+
i += 1;
|
|
112
|
+
if (i >= argv.length) throw new UsageError(`${arg} requires a value`);
|
|
113
|
+
return argv[i];
|
|
114
|
+
};
|
|
115
|
+
if (arg === '--pattern') opts.pattern = next();
|
|
116
|
+
else if (arg === '--pattern-file') opts.patternFile = next();
|
|
117
|
+
else if (arg === '--path') opts.paths.push(next());
|
|
118
|
+
else if (arg === '--max') opts.max = parseCount(next(), '--max', HARD_MAX_RESULTS);
|
|
119
|
+
else if (arg === '--max-bytes') opts.maxBytes = parseCount(next(), '--max-bytes', HARD_MAX_FILE_BYTES);
|
|
120
|
+
else if (arg === '--json') opts.json = true;
|
|
121
|
+
else throw new UsageError(`unknown argument: ${arg} (see --help)`);
|
|
122
|
+
}
|
|
123
|
+
if (opts.pattern !== null && opts.patternFile !== null) {
|
|
124
|
+
throw new UsageError('--pattern and --pattern-file are mutually exclusive — the lane must be unambiguous');
|
|
125
|
+
}
|
|
126
|
+
if (opts.pattern === null && opts.patternFile === null) throw new UsageError('one of --pattern or --pattern-file is required');
|
|
127
|
+
if (opts.paths.length === 0) opts.paths.push('.');
|
|
128
|
+
return opts;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Containment on the REAL path. A lexical check passes `link/secret.txt` whenever `link` resolves
|
|
132
|
+
// outside, and on Windows a cross-drive `relative()` returns an absolute path carrying no `..` —
|
|
133
|
+
// both were live review findings, not hypotheticals.
|
|
134
|
+
export const resolveTarget = (realRoot, target) => {
|
|
135
|
+
const lexical = resolve(realRoot, target);
|
|
136
|
+
let real;
|
|
137
|
+
try {
|
|
138
|
+
real = realpathSync(lexical);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err?.code === 'ENOENT') throw new IoError(`no such path: ${target}`);
|
|
141
|
+
throw new IoError(`cannot resolve ${target} (${err?.code ?? err?.message ?? err})`);
|
|
142
|
+
}
|
|
143
|
+
const rel = relative(realRoot, real);
|
|
144
|
+
if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) {
|
|
145
|
+
throw new IoError(`target resolves outside the search root: ${target}`);
|
|
146
|
+
}
|
|
147
|
+
return real;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Open → fstat the DESCRIPTOR → read bounded. The descriptor is what was actually opened, so a swap
|
|
151
|
+
// after the check cannot substitute a different node; O_NOFOLLOW refuses a symlinked leaf outright.
|
|
152
|
+
const readRegularFile = (abs, maxBytes, state, io = {}) => {
|
|
153
|
+
const open = io.open ?? openSync;
|
|
154
|
+
const fstat = io.fstat ?? fstatSync;
|
|
155
|
+
const read = io.read ?? readSync;
|
|
156
|
+
const close = io.close ?? closeSync;
|
|
157
|
+
let fd;
|
|
158
|
+
try {
|
|
159
|
+
fd = open(abs, OPEN_FLAGS);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// ELOOP is a symlink refused by O_NOFOLLOW — a counted skip, not an error.
|
|
162
|
+
if (err?.code === 'ELOOP') state.skipped.symlinks += 1;
|
|
163
|
+
else state.skipped.unreadable += 1;
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const stat = fstat(fd);
|
|
168
|
+
if (!stat.isFile()) {
|
|
169
|
+
state.skipped.special += 1;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (stat.size > maxBytes) {
|
|
173
|
+
state.skipped.large += 1;
|
|
174
|
+
// A skipped file is NOT a silent omission: the search is incomplete and says which bound did
|
|
175
|
+
// it. Reporting it only as a counter would let a partial search read as "no matches".
|
|
176
|
+
if (state.incomplete === null) {
|
|
177
|
+
state.incomplete = { bound: 'max-file-bytes', detail: `at least one file exceeds ${maxBytes} byte(s) and was not searched` };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const buf = Buffer.allocUnsafe(stat.size);
|
|
182
|
+
let got = 0;
|
|
183
|
+
while (got < stat.size) {
|
|
184
|
+
const n = read(fd, buf, got, stat.size - got, got);
|
|
185
|
+
if (n <= 0) break;
|
|
186
|
+
got += n;
|
|
187
|
+
}
|
|
188
|
+
// A short read means the file changed under us. Returning the partial buffer would let a
|
|
189
|
+
// truncated file come back as a confident "no matches" — the file is classified unreadable
|
|
190
|
+
// instead, which is counted and visible.
|
|
191
|
+
if (got !== stat.size) {
|
|
192
|
+
state.skipped.unreadable += 1;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return buf;
|
|
196
|
+
} catch {
|
|
197
|
+
state.skipped.unreadable += 1;
|
|
198
|
+
return null;
|
|
199
|
+
} finally {
|
|
200
|
+
close(fd);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const isBinary = (buf) => buf.subarray(0, BINARY_SNIFF_BYTES).includes(0);
|
|
205
|
+
|
|
206
|
+
// Whole-buffer search, so a MULTILINE pattern matches; the line number is derived from the offset.
|
|
207
|
+
// The newline cursor is carried ACROSS matches rather than recounted from zero for each one — with
|
|
208
|
+
// the result cap at six figures, recounting is quadratic in the file length and burns the event
|
|
209
|
+
// loop on exactly the large files a search is aimed at.
|
|
210
|
+
const searchBuffer = (buf, pattern, relPath, state) => {
|
|
211
|
+
const text = buf.toString('utf8');
|
|
212
|
+
let from = 0;
|
|
213
|
+
let line = 1;
|
|
214
|
+
let counted = 0;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const at = text.indexOf(pattern, from);
|
|
217
|
+
if (at === -1) return;
|
|
218
|
+
if (state.matches.length >= state.max) {
|
|
219
|
+
state.incomplete = { bound: 'max-results', detail: `stopped at ${state.max} result(s); more may exist` };
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (let i = counted; i < at; i += 1) if (text.charCodeAt(i) === 10) line += 1;
|
|
223
|
+
counted = at;
|
|
224
|
+
// A pattern that STARTS on a newline would otherwise take that same byte as its own line start
|
|
225
|
+
// and report an empty snippet; searching back from the byte BEFORE it reports the line the match
|
|
226
|
+
// actually begins on.
|
|
227
|
+
const back = text.charCodeAt(at) === 10 ? Math.max(0, at - 1) : at;
|
|
228
|
+
const lineStart = text.lastIndexOf('\n', back) + 1;
|
|
229
|
+
// The snippet must span the WHOLE match: taking the line end from the match's START truncates a
|
|
230
|
+
// multiline pattern at its first newline, so the snippet could exclude the very text that
|
|
231
|
+
// matched (`\nbeta` in "alpha\nbeta" reported "alpha"). It is taken from the match's END.
|
|
232
|
+
const lineEndRaw = text.indexOf('\n', at + pattern.length);
|
|
233
|
+
const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
|
|
234
|
+
// The snippet is WINDOWED around the match, not the whole line. A minified file is one enormous
|
|
235
|
+
// line, and storing it per match turns a 200-result search into hundreds of megabytes — the
|
|
236
|
+
// bound has to apply to what is kept, not only to how many are kept.
|
|
237
|
+
const from0 = Math.max(lineStart, at - SNIPPET_CONTEXT);
|
|
238
|
+
const to0 = Math.min(lineEnd, at + pattern.length + SNIPPET_CONTEXT);
|
|
239
|
+
const windowed = `${from0 > lineStart ? '…' : ''}${text.slice(from0, to0)}${to0 < lineEnd ? '…' : ''}`;
|
|
240
|
+
const snippet = windowed.length > SNIPPET_MAX ? `${windowed.slice(0, SNIPPET_MAX - 1)}…` : windowed;
|
|
241
|
+
state.matches.push({ file: relPath, line, text: snippet });
|
|
242
|
+
from = at + Math.max(1, pattern.length);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const spend = (state) => {
|
|
247
|
+
if (state.walked >= state.walkBudget) {
|
|
248
|
+
if (state.incomplete === null) {
|
|
249
|
+
state.incomplete = { bound: 'walk-budget', detail: `stopped after ${state.walkBudget} entries; the tree was not fully traversed` };
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
state.walked += 1;
|
|
254
|
+
return true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
|
|
258
|
+
if (state.incomplete !== null || !spend(state)) return;
|
|
259
|
+
|
|
260
|
+
let stat;
|
|
261
|
+
try {
|
|
262
|
+
stat = (state.io.lstat ?? lstatSync)(abs);
|
|
263
|
+
} catch {
|
|
264
|
+
state.skipped.unreadable += 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (stat.isSymbolicLink()) {
|
|
268
|
+
state.skipped.symlinks += 1;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (stat.isDirectory()) {
|
|
272
|
+
// The default prune never applies to a directory the caller NAMED: `--path node_modules` asking
|
|
273
|
+
// for nothing back, silently, is a worse answer than searching it.
|
|
274
|
+
if (!isExplicitTarget && NEVER_WALKED.includes(abs.split(sep).pop())) return;
|
|
275
|
+
let dir;
|
|
276
|
+
try {
|
|
277
|
+
dir = (state.io.opendir ?? opendirSync)(abs);
|
|
278
|
+
} catch {
|
|
279
|
+
state.skipped.unreadable += 1;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
// Incremental: the budget is consulted before each entry, so one enormous directory cannot
|
|
284
|
+
// force unbounded work (or unbounded memory) before the first check.
|
|
285
|
+
for (;;) {
|
|
286
|
+
const entry = dir.readSync();
|
|
287
|
+
if (entry === null) break;
|
|
288
|
+
if (state.incomplete !== null) break;
|
|
289
|
+
walk(root, join(abs, entry.name), pattern, state);
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
dir.closeSync();
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (state.excludePath !== null && abs === state.excludePath) {
|
|
297
|
+
state.skipped.patternFile += 1;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const buf = readRegularFile(abs, state.maxBytes, state, state.io);
|
|
301
|
+
if (buf === null) return;
|
|
302
|
+
if (isBinary(buf)) {
|
|
303
|
+
state.skipped.binary += 1;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
searchBuffer(buf, pattern, relative(root, abs) || abs, state);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// `io` injects the filesystem primitives so the failure branches — a vanished entry, an unopenable
|
|
310
|
+
// directory, a symlink refused by O_NOFOLLOW, a file truncated mid-read — are reachable from tests.
|
|
311
|
+
// Every one of them is a COUNTED skip in production, and a counted skip that no test ever exercises
|
|
312
|
+
// is indistinguishable from a silent one.
|
|
313
|
+
export const search = ({ root, pattern, paths, max, maxBytes, excludePath = null, walkBudget = DEFAULT_WALK_BUDGET, io = {} }) => {
|
|
314
|
+
const state = {
|
|
315
|
+
matches: [],
|
|
316
|
+
incomplete: null,
|
|
317
|
+
skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0 },
|
|
318
|
+
excludePath,
|
|
319
|
+
io,
|
|
320
|
+
walked: 0,
|
|
321
|
+
walkBudget,
|
|
322
|
+
max,
|
|
323
|
+
maxBytes,
|
|
324
|
+
};
|
|
325
|
+
for (const target of paths) {
|
|
326
|
+
// The target list is bounded too: a caller passing thousands of --path values would otherwise
|
|
327
|
+
// spend unbounded work resolving them while walk() returns immediately.
|
|
328
|
+
if (state.incomplete !== null) break;
|
|
329
|
+
walk(root, resolveTarget(root, target), pattern, state, true);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
pattern: patternDigest(pattern),
|
|
333
|
+
matches: state.matches,
|
|
334
|
+
incomplete: state.incomplete,
|
|
335
|
+
skipped: state.skipped,
|
|
336
|
+
scanned: state.walked,
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const formatResult = (result) => {
|
|
341
|
+
const lines = [
|
|
342
|
+
`repo-search — literal pattern sha256:${result.pattern.digest} (${result.pattern.bytes} byte(s)) · ${result.scanned} entr(ies) scanned`,
|
|
343
|
+
];
|
|
344
|
+
for (const m of result.matches) lines.push(`${m.file}:${m.line}: ${m.text}`);
|
|
345
|
+
if (result.matches.length === 0) lines.push(' no matches');
|
|
346
|
+
const skips = Object.entries(result.skipped).filter(([, n]) => n > 0);
|
|
347
|
+
if (skips.length) lines.push(` skipped: ${skips.map(([k, n]) => `${k}=${n}`).join(', ')}`);
|
|
348
|
+
if (result.incomplete) lines.push(` ⚠ INCOMPLETE (${result.incomplete.bound}): ${result.incomplete.detail}`);
|
|
349
|
+
return lines.join('\n');
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const HELP = `repo-search — literal repository search that never has to ride a shell metacharacter.
|
|
353
|
+
|
|
354
|
+
Usage:
|
|
355
|
+
node repo-search.mjs --pattern <literal> [--path <p>]... [--max <n>] [--max-bytes <n>] [--json]
|
|
356
|
+
node repo-search.mjs --pattern-file <path> [--path <p>]... [--max <n>] [--max-bytes <n>] [--json]
|
|
357
|
+
|
|
358
|
+
--pattern-file is the lane for a pattern carrying shell-significant bytes (\`>\`, \`$(\`, a backtick):
|
|
359
|
+
its bytes never enter the command string, so the residual guard has nothing to scan. Write the file
|
|
360
|
+
with your host's file-write tool, then pass the plain path here, and delete it when you are done —
|
|
361
|
+
this tool never writes.
|
|
362
|
+
|
|
363
|
+
LITERAL only, multiline patterns supported. Reads regular files only, opened no-follow. Skipped
|
|
364
|
+
entries (symlinks, non-regular, binary, oversized, unreadable) are counted and reported, never
|
|
365
|
+
dropped silently; an oversized file additionally makes the whole search INCOMPLETE.
|
|
366
|
+
|
|
367
|
+
Exit codes: 0 search completed (matches or an explicitly empty result) · 1 I/O failure or refusal ·
|
|
368
|
+
2 usage / invalid input · 3 completed but INCOMPLETE (a bound fired; the bound is named).`;
|
|
369
|
+
|
|
370
|
+
export const main = (argv, ctx = {}) => {
|
|
371
|
+
try {
|
|
372
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: EXIT_OK, stdout: HELP, stderr: '', result: null };
|
|
373
|
+
const root = realpathSync(resolve(ctx.cwd ?? process.cwd()));
|
|
374
|
+
const opts = parseArgs(argv);
|
|
375
|
+
let raw;
|
|
376
|
+
let excludePath = null;
|
|
377
|
+
if (opts.patternFile !== null) {
|
|
378
|
+
excludePath = resolveTarget(root, opts.patternFile);
|
|
379
|
+
const state = { skipped: { symlinks: 0, special: 0, unreadable: 0, large: 0 }, incomplete: null };
|
|
380
|
+
const buf = readRegularFile(excludePath, HARD_MAX_FILE_BYTES, state);
|
|
381
|
+
if (buf === null) throw new IoError(`cannot read --pattern-file ${opts.patternFile} as a regular file`);
|
|
382
|
+
raw = buf.toString('utf8');
|
|
383
|
+
} else {
|
|
384
|
+
raw = opts.pattern;
|
|
385
|
+
}
|
|
386
|
+
const pattern = opts.patternFile !== null ? resolvePattern(raw) : raw;
|
|
387
|
+
if (pattern === '') throw new UsageError('the pattern is empty — it would match every line of every file');
|
|
388
|
+
|
|
389
|
+
const result = search({ root, pattern, paths: opts.paths, max: opts.max, maxBytes: opts.maxBytes, excludePath });
|
|
390
|
+
const stdout = opts.json ? JSON.stringify(result, null, 2) : formatResult(result);
|
|
391
|
+
return { code: result.incomplete ? EXIT_INCOMPLETE : EXIT_OK, stdout, stderr: '', result };
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err instanceof UsageError) return { code: EXIT_USAGE, stdout: '', stderr: `repo-search: ${err.message}`, result: null };
|
|
394
|
+
if (err instanceof IoError) return { code: EXIT_ERROR, stdout: '', stderr: `repo-search: ${err.message}`, result: null };
|
|
395
|
+
return { code: EXIT_ERROR, stdout: '', stderr: `repo-search: ${err?.message ?? err}`, result: null };
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const emitResult = (r) => {
|
|
400
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
401
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
402
|
+
process.exitCode = r.code;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
406
|
+
if (isDirectRun) emitResult(main(process.argv.slice(2)));
|
package/tools/run-gates.mjs
CHANGED
|
@@ -34,8 +34,14 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
34
34
|
import { computeTreeFingerprint } from './review-state.mjs';
|
|
35
35
|
// The D3(a) final receipt rides the core-evidence SOLE WRITER (the sole-writer boundary — this
|
|
36
36
|
// runner never opens the store itself) + the canonical per-kind serialization its hashes bind.
|
|
37
|
-
import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION } from './core-evidence.mjs';
|
|
38
|
-
import {
|
|
37
|
+
import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION, resolveBase } from './core-evidence.mjs';
|
|
38
|
+
import {
|
|
39
|
+
LCOV_BASENAME,
|
|
40
|
+
commitmentFor,
|
|
41
|
+
ATTEST_NONCE_ENV,
|
|
42
|
+
ATTEST_FINGERPRINT_ENV,
|
|
43
|
+
ATTEST_BASE_ENV,
|
|
44
|
+
} from './coverage-check.mjs';
|
|
39
45
|
|
|
40
46
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
41
47
|
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
@@ -196,10 +202,19 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
196
202
|
// silently attest against the wrong git dir or lcov instead of the computed one.
|
|
197
203
|
export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
|
|
198
204
|
|
|
205
|
+
// The attestation variables ride the same STRIP but are NOT producer variables: a producer variable
|
|
206
|
+
// is something a gate cmd may legitimately reference, and a missing one refuses the run up front.
|
|
207
|
+
// A capability is the opposite — no gate may reference it, exactly one gate is handed it, and every
|
|
208
|
+
// other child (and any descendant it spawns) must see it absent, host-set copies included, or that
|
|
209
|
+
// descendant could certify a foreign lcov later. Conflating the two lists made a gate that merely
|
|
210
|
+
// MENTIONS the name refuse the whole run.
|
|
211
|
+
export const RESERVED_CAPABILITY_ENV = Object.freeze([ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]);
|
|
212
|
+
|
|
199
213
|
export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
|
|
200
214
|
const env = { ...process.env };
|
|
201
215
|
delete env.NODE_TEST_CONTEXT;
|
|
202
216
|
for (const name of RESERVED_PRODUCER_ENV) delete env[name];
|
|
217
|
+
for (const name of RESERVED_CAPABILITY_ENV) delete env[name];
|
|
203
218
|
return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
204
219
|
};
|
|
205
220
|
|
|
@@ -327,13 +342,22 @@ const matchesCanonicalCheck = (check, cmd, projectDir) => {
|
|
|
327
342
|
}
|
|
328
343
|
};
|
|
329
344
|
|
|
345
|
+
// canonicalCheckerGates(gates, projectDir) → every gate that IS the canonical coverage-check. The
|
|
346
|
+
// count is load-bearing twice over: --final refuses more than one (the attestation capability would
|
|
347
|
+
// reach more than one process) and this predicate must refuse the same declaration, or a consumer
|
|
348
|
+
// would advertise final-capability for a declaration --final then rejects.
|
|
349
|
+
export const canonicalCheckerGates = (gates, projectDir) =>
|
|
350
|
+
gates.filter((g) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], g.cmd, projectDir));
|
|
351
|
+
|
|
330
352
|
// isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
|
|
331
|
-
// (every canonical core check present +
|
|
332
|
-
// recommendations guard-install probe) read instead
|
|
353
|
+
// (every canonical core check present + EXACTLY ONE canonical checker + that checker LAST) — the
|
|
354
|
+
// ONE home consumers (the recommendations guard-install probe, the worktrees report) read instead
|
|
355
|
+
// of re-deriving the rule.
|
|
333
356
|
export const isFinalCapableDeclaration = (gates, projectDir) => {
|
|
334
357
|
if (!Array.isArray(gates) || gates.length === 0) return false;
|
|
335
358
|
const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
|
|
336
359
|
if (missing.length > 0) return false;
|
|
360
|
+
if (canonicalCheckerGates(gates, projectDir).length !== 1) return false;
|
|
337
361
|
return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
|
|
338
362
|
};
|
|
339
363
|
const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
@@ -389,6 +413,13 @@ export const runCli = (argv, deps = {}) => {
|
|
|
389
413
|
if (missing.length > 0) {
|
|
390
414
|
throw fail(EXIT.malformed, `--final refuses a weakened declaration — missing the canonical core check(s): ${missing.map((c) => c.name).join(', ')} (each must be ONE plain --check invocation of the kit's OWN tool in ${GATES_REL} — a masked form, a compound, or a lookalike path never counts)`);
|
|
391
415
|
}
|
|
416
|
+
// EXACTLY ONE canonical checker. Two gates may carry the same canonical cmd under different
|
|
417
|
+
// ids, and the capability is handed to every match — so without this the "exactly one gate
|
|
418
|
+
// holds it" guarantee is prose, and an extra copy would run with a live attestation context.
|
|
419
|
+
const canonicalCheckers = canonicalCheckerGates(declaration.gates, projectDir);
|
|
420
|
+
if (canonicalCheckers.length > 1) {
|
|
421
|
+
throw fail(EXIT.malformed, `--final refuses the declaration — ${canonicalCheckers.length} gates are the canonical coverage-check (${canonicalCheckers.map((g) => JSON.stringify(g.id)).join(', ')}); exactly ONE may be, or the attestation context would be handed to more than one process`);
|
|
422
|
+
}
|
|
392
423
|
const lastGate = declaration.gates[declaration.gates.length - 1];
|
|
393
424
|
if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], lastGate.cmd, projectDir)) {
|
|
394
425
|
throw fail(EXIT.malformed, `--final refuses the declaration — the CANONICAL coverage-check gate must be the LAST declared gate (nothing may run after the checker consumed the lcov; "${lastGate.id}" is declared last)`);
|
|
@@ -430,7 +461,29 @@ export const runCli = (argv, deps = {}) => {
|
|
|
430
461
|
}
|
|
431
462
|
// --final needs the pre-run fingerprint (the receipt binds before == after == current).
|
|
432
463
|
const finalFingerprintBefore = opts.final ? fingerprint(projectDir) : null;
|
|
433
|
-
const
|
|
464
|
+
const finalBase = opts.final ? resolveBase(projectDir) ?? '' : null;
|
|
465
|
+
// The attestation handshake (see coverage-check.mjs): a fresh random nonce rides the child
|
|
466
|
+
// environment, and the attempt id this run records is the one-way COMMITMENT over it plus the
|
|
467
|
+
// identity. Persisting only the commitment is what makes the context unreproducible from the
|
|
468
|
+
// repository afterwards — a plain recorded id is public, and attesting from one let an ordinary
|
|
469
|
+
// interrupted run certify a later run's lcov. The commitment is also the only place the BASE is
|
|
470
|
+
// bound, since no record stores it.
|
|
471
|
+
const finalNonce = opts.final ? randomUUID() : null;
|
|
472
|
+
const finalAttempt = opts.final ? commitmentFor(finalNonce, finalFingerprintBefore ?? '', finalBase) : null;
|
|
473
|
+
if (opts.final) {
|
|
474
|
+
// ONLY the canonical checker receives the capability — the same predicate the --final preflight
|
|
475
|
+
// uses to recognise it. Every other gate gets the producer variables and nothing else.
|
|
476
|
+
gateSpawn = (cmd, cwd2) => {
|
|
477
|
+
const producers = { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) };
|
|
478
|
+
if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], cmd, projectDir)) return spawn(cmd, cwd2, producers);
|
|
479
|
+
return spawn(cmd, cwd2, {
|
|
480
|
+
...producers,
|
|
481
|
+
[ATTEST_NONCE_ENV]: finalNonce,
|
|
482
|
+
[ATTEST_FINGERPRINT_ENV]: finalFingerprintBefore ?? '',
|
|
483
|
+
[ATTEST_BASE_ENV]: finalBase,
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
}
|
|
434
487
|
let finalError = null;
|
|
435
488
|
let startEvidenceHashes = null;
|
|
436
489
|
if (opts.final) {
|
|
@@ -458,6 +511,19 @@ export const runCli = (argv, deps = {}) => {
|
|
|
458
511
|
const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
|
|
459
512
|
for (const line of formatTable(results)) log(line);
|
|
460
513
|
const allGreen = results.every((result) => result.ok);
|
|
514
|
+
// A green gate's stdout is deliberately not echoed — the table IS the report. But the checker
|
|
515
|
+
// exits 0 both when it certifies and when it WITHHOLDS a verdict, so on a plain run the table
|
|
516
|
+
// would read PASS over a coverage claim that was never made: the same false reassurance one
|
|
517
|
+
// layer up from the defect this whole mechanism exists to close. Surface it, and only it.
|
|
518
|
+
if (!opts.final) {
|
|
519
|
+
const checkerAt = selected.findIndex((gate) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gate.cmd, projectDir));
|
|
520
|
+
const checkerRow = checkerAt === -1 ? null : results[checkerAt];
|
|
521
|
+
if (checkerRow?.ok && /^coverage-check: attested=no$/m.test(String(checkerRow.stdout ?? ''))) {
|
|
522
|
+
log(`── ${checkerRow.id} — NO COVERAGE VERDICT (the gate passed; it did not certify)`);
|
|
523
|
+
for (const line of String(checkerRow.stdout).split(/\r?\n/).filter((l) => /^coverage-check: (NO VERDICT|skipped-no-lcov)/.test(l))) log(line);
|
|
524
|
+
log(' A coverage verdict is issued only by run-gates.mjs --final, which owns the lcov for the whole run.');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
461
527
|
if (opts.final) {
|
|
462
528
|
// The checker's verbatim diagnostics surface even on green — skipped-no-lcov and the
|
|
463
529
|
// out-of-domain/unsupported lists must never vanish into a suppressed green stdout.
|
|
@@ -493,6 +559,22 @@ export const runCli = (argv, deps = {}) => {
|
|
|
493
559
|
const shaLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => shaLineRe.test(l));
|
|
494
560
|
const shaValue = shaLines.length === 1 ? shaLineRe.exec(shaLines[0])[1] : null;
|
|
495
561
|
const lcovSha256 = shaValue !== null && shaValue !== 'none' ? shaValue : null;
|
|
562
|
+
// The attestation line, on the SAME exactly-one-anchored-line contract as the sha: a green
|
|
563
|
+
// exit status alone never proves the checker certified anything — it exits 0 both when it
|
|
564
|
+
// attests and when it withholds a verdict. Without this arm a gate that removed the start
|
|
565
|
+
// record mid-run would yield a green receipt carrying no coverage claim at all.
|
|
566
|
+
const attestLineRe = /^coverage-check: attested=(yes|no)$/;
|
|
567
|
+
const attestLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => attestLineRe.test(l));
|
|
568
|
+
const attested = attestLines.length === 1 ? attestLineRe.exec(attestLines[0])[1] : null;
|
|
569
|
+
if (allGreen && integrityFailure === null && lcovSha256 !== null) {
|
|
570
|
+
if (attestLines.length !== 1) {
|
|
571
|
+
integrityFailure = attestLines.length === 0
|
|
572
|
+
? 'the coverage-check gate printed no attested= line — whether coverage was certified is unknowable (fail closed)'
|
|
573
|
+
: `the coverage-check gate printed ${attestLines.length} attested= lines — exactly ONE full machine line binds the receipt`;
|
|
574
|
+
} else if (attested !== 'yes') {
|
|
575
|
+
integrityFailure = 'the coverage-check gate consumed an lcov but did NOT certify it — the final run reached the checker without a valid attestation context';
|
|
576
|
+
}
|
|
577
|
+
}
|
|
496
578
|
if (allGreen && integrityFailure === null) {
|
|
497
579
|
if (shaLines.length !== 1) {
|
|
498
580
|
integrityFailure = shaLines.length === 0
|
|
@@ -543,7 +625,10 @@ export const runCli = (argv, deps = {}) => {
|
|
|
543
625
|
logError(`[run-gates] --final could not write its receipt: ${err.message}`);
|
|
544
626
|
}
|
|
545
627
|
}
|
|
546
|
-
|
|
628
|
+
// The summary line is the MACHINE report, so it must agree with the exit code: an integrity
|
|
629
|
+
// failure mints a RED receipt and exits finalFailed, and a line still saying status=ok there
|
|
630
|
+
// would be a silent green in the one place a reader parses instead of reads.
|
|
631
|
+
log(composeSummaryLine({ status: allGreen && finalError === null ? 'ok' : 'fail', results }));
|
|
547
632
|
if (finalError) return EXIT.finalFailed;
|
|
548
633
|
return allGreen ? EXIT.ok : EXIT.fail;
|
|
549
634
|
} catch (err) {
|
|
@@ -144,6 +144,11 @@ export const KIT_READONLY_TOOLS = Object.freeze([
|
|
|
144
144
|
KIT_RUN_GATES_TOOL,
|
|
145
145
|
'tools/manifest/validate.mjs',
|
|
146
146
|
'tools/release-scan.mjs',
|
|
147
|
+
// The literal search lane. It is in the tier for the ordinary reason (a pure reader the agent
|
|
148
|
+
// calls constantly), and its promptlessness additionally depends on it: a non-core command gets
|
|
149
|
+
// NO decision from the hook, which is not the same as an allow, so without this rule the lane
|
|
150
|
+
// falls through to whatever the host policy happens to be.
|
|
151
|
+
'tools/repo-search.mjs',
|
|
147
152
|
]);
|
|
148
153
|
// Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
|
|
149
154
|
// --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
|