@blamejs/exceptd-skills 0.18.5 → 0.18.7
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 +24 -0
- package/bin/exceptd.js +277 -60
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +24 -3
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +375 -40
- package/lib/prefetch.js +6 -1
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +201 -24
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +106 -13
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +19 -3
- package/lib/validate-indexes.js +62 -1
- package/lib/validate-playbooks.js +4 -1
- package/lib/validate-vendor.js +69 -8
- package/manifest.json +53 -53
- package/orchestrator/index.js +69 -13
- package/package.json +1 -1
- package/sbom.cdx.json +124 -124
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +58 -4
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +20 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.7 — 2026-06-20
|
|
4
|
+
|
|
5
|
+
Escalation and feeds_into conditions that use an `any`/`all` quantifier prefix, a quoted `matches '…'` pattern, or a quoted `contains` member now evaluate instead of silently failing closed. Several catalog chains that depended on these forms — most visibly the compliance-theater → SBOM correlation — were dead and now fire; the feeds_into evaluation context also exposes the bare `blast_radius_score`, `theater_verdict`, and `compliance_theater_check` tokens the catalog references, so an engine-computed value is no longer lost to a `null` lookup. The SBOM → kernel / MCP / AI-API follow-ups now fire too: a matched CVE carries an `attack_class`, the analyze and close contexts expose the matched-CVE array the `any matched_cve.attack_class == …` rules quantify over, and the CVEs that map unambiguously to one of those deep-dive classes are tagged in the catalog — so an SBOM scan that surfaces an MCP-server RCE or a prompt-injection-to-RCE routes the operator into the matching deep dive, while an unclassified match routes nowhere rather than guessing. `IN […]` member lists, AND/OR joins, and outer parentheses are parsed quote-aware, so a comma, bracket, or operator inside a quoted value no longer tears the clause, and a `contains`/`IN` against a path that doesn't resolve records a diagnostic instead of evaluating to an invisible false.
|
|
6
|
+
|
|
7
|
+
RWEP scoring no longer silently drops the active-exploitation weight when a CVE's `active_exploitation` value is outside the recognised vocabulary or merely stray-cased (`Confirmed`, ` CONFIRMED `): canonical values are case-normalised, and a genuinely unrecognised value contributes zero **and** emits a warning so the dropped weight is visible rather than quietly lowering the priority. Curation now rejects a mixed-shape `rwep_factors` block (post-weight integers with a stray non-numeric value) at apply time instead of letting it route through the wrong scoring path and write a silently-wrong RWEP to the catalog. The scorer's two factor shapes no longer disagree on that block — the derive path applies the same mixed-shape guard the validator does, so it can't fall back to the boolean path and under-score — the Shape-B sum clamps each weight to its ceiling, and an SLA timeline is derived only from a real number rather than treating a NaN as the least-urgent tier.
|
|
8
|
+
|
|
9
|
+
`attest diff` no longer manufactures phantom drift when one attestation carries real evidence and the other is empty — the full-catalog placeholder now stands in only when both sides are empty, so a populated side diffs against nothing and yields the genuine added/removed instead of every catalog id reading as drift. It also no longer masks a real change when an artifact's secret or path is carried under a key other than `value`: that evidence is preserved through capture and compared, so two attestations recording different secrets at different paths no longer diff as unchanged.
|
|
10
|
+
|
|
11
|
+
An empty-string flag value is rejected rather than silently degrading the command. `--evidence ""`, `--evidence-dir ""`, `--phase ""`, `--against ""`, `--playbook ""`, and `--session-id ""` now error instead of running with no scope or bypassing their phase / target / dispatcher validation, and `attest export` redacts `signal_overrides` in its labeled output instead of emitting them verbatim.
|
|
12
|
+
|
|
13
|
+
The secrets collector no longer double-reports an Anthropic `sk-ant-…` key as both an OpenAI and an Anthropic key, and now surfaces subtrees skipped for exceeding the depth cap instead of leaving them silently unscanned. The collectors and the CVE-cache reader read each file through to end-of-file, so a key or misconfiguration sitting past a short-read boundary on a network or sync-backed filesystem is no longer dropped and the scan no longer reports clean over content it never decoded. The library-author collector no longer flags npm-workspaces lockfile links (`link: true`) as missing integrity — a false positive on every workspaces monorepo. The CI/CD collector matches the GitHub OIDC issuer and a `GITHUB_TOKEN` secret name exactly, so a look-alike issuer host or a custom `GITHUB_TOKEN_*` secret is no longer mis-handled.
|
|
14
|
+
|
|
15
|
+
The network-backed pollers (OSV, GHSA, the Datatracker freshness probe, the package prefetch, and the watchlist org scan) retry transient failures with backoff and cap response size instead of buffering an unbounded body into memory; the prefetch retry classifier reads the error status under the field the retry helper sets, so a 429/5xx is retried rather than treated as fatal. The vendored-tree integrity check fails closed when a license hash or a per-file hash is absent — a stripped hash no longer reads as verified.
|
|
16
|
+
|
|
17
|
+
CVE-catalog validation rejects impossible calendar dates (for example `2026-02-30`, or Feb 29 in a non-leap year) on KEV deadline fields instead of letting them roll over to a valid-looking date, and the public-exploit URL classifier is anchored so a look-alike host such as `exploit-db.com.attacker.example` is no longer treated as a public-exploit source.
|
|
18
|
+
|
|
19
|
+
The index-freshness gate now verifies that every derived index file still exists and parses, not only that the source hashes match — a deleted or truncated index no longer passes as current. `refresh --network` and the vendored-file online validator pin their fetches to the npm registry / GitHub hosts, so a tampered metadata response or redirect cannot steer a download at an arbitrary address. `refresh --network` additionally verifies the npm registry's signature over the fetched tarball and authenticates the `data/` tree and manifest snapshot before swapping them in, so a forged metadata response that recomputes the transport hashes is still refused. The live CVE-validation downgrade guard now reads the upstream metric's declared CVSS version, so a legacy v2 score NVD returns without a vector string is still recognised as a downgrade and held back from a curated v3.x entry — matching the cached refresh path, which already used the declared version.
|
|
20
|
+
|
|
21
|
+
Internal: the diff-coverage export extractor and the codebase-pattern detectors are now string-aware, so a brace or `//` inside a string literal can no longer hide a new export from the coverage gate or disarm the process-exit / dynamic-regex detectors. Temp files in the test suite are created inside owner-only directories. The signing-key bootstrap creates each key with an exclusive (`O_EXCL`) open so an existing key is refused atomically rather than via a separate existence check, and the index validator opens derived files with `O_NOFOLLOW` and reads them through the descriptor, removing the check-then-read windows static analysis flagged. The signer schema-validates the manifest before writing a signature, so it can no longer bless a manifest the verifier would reject at install time. Several gate detectors close blind spots: the test-count gate ignores `test(` inside a multiline template literal, the export extractor sees method-shorthand and single-identifier `module.exports`, the process-exit detector covers `scripts/` and `bin/`, the version-tag detector no longer counts an IPv4 octet run, and `build-indexes --changed` regenerates a deleted index instead of trusting a clean source hash and no longer rewrites a timestamp on a no-op run.
|
|
22
|
+
|
|
23
|
+
## 0.18.6 — 2026-06-14
|
|
24
|
+
|
|
25
|
+
`attest prune` now ages out sessions that hold only replay records (whose attestation was removed). Such a session had no datable timestamp, so it was kept indefinitely and the attestation store could grow without bound; it is now dated by its newest replay timestamp and pruned past the cutoff like any other session.
|
|
26
|
+
|
|
3
27
|
## 0.18.5 — 2026-06-14
|
|
4
28
|
|
|
5
29
|
The manifest's per-skill cross-reference arrays — data dependencies, framework gaps, and ATT&CK / ATLAS / D3FEND / RFC / CWE references — now include every reference a skill declares in its own frontmatter. Previously a reference present in a skill's frontmatter but missing from the manifest cache could silently drop out of the reverse-reference surfaces (for instance, a skill not appearing under a D3FEND technique it actually maps). A predeploy gate now holds the manifest cache to cover frontmatter, so the reference graph cannot drift again.
|
package/bin/exceptd.js
CHANGED
|
@@ -60,7 +60,7 @@ const PKG_ROOT = path.resolve(__dirname, "..");
|
|
|
60
60
|
// constants so a new verb cannot regress the exit-code contract by typo,
|
|
61
61
|
// and so the help-text dump (`doctor --exit-codes`) and the runtime
|
|
62
62
|
// behavior share the same source of truth.
|
|
63
|
-
const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
|
|
63
|
+
const { EXIT_CODES, listExitCodes, safeExit } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
|
|
64
64
|
const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
|
|
65
65
|
const { suggestFlag, flagsFor, VERB_FLAG_ALLOWLIST } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
|
|
66
66
|
const codepointClass = require(path.join(PKG_ROOT, "vendor", "blamejs", "codepoint-class.js"));
|
|
@@ -612,7 +612,7 @@ function main() {
|
|
|
612
612
|
}
|
|
613
613
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
614
614
|
process.stdout.write(readPkgVersion() + "\n");
|
|
615
|
-
|
|
615
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
616
616
|
}
|
|
617
617
|
if (cmd === "path") {
|
|
618
618
|
// v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
|
|
@@ -638,14 +638,14 @@ function main() {
|
|
|
638
638
|
if (copied) {
|
|
639
639
|
process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
|
|
640
640
|
process.stdout.write(PKG_ROOT + "\n");
|
|
641
|
-
|
|
641
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
642
642
|
}
|
|
643
643
|
process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
|
|
644
644
|
process.stdout.write(PKG_ROOT + "\n");
|
|
645
|
-
|
|
645
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
646
646
|
}
|
|
647
647
|
process.stdout.write(PKG_ROOT + "\n");
|
|
648
|
-
|
|
648
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
649
649
|
}
|
|
650
650
|
|
|
651
651
|
// v0.13.0: hard-refuse the v0.10.x legacy verbs that were
|
|
@@ -1157,9 +1157,9 @@ function hasReadableStdin() {
|
|
|
1157
1157
|
* on failure so the caller can wrap it with its own verb prefix.
|
|
1158
1158
|
*/
|
|
1159
1159
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
1160
|
-
function validateIsoSince(raw) {
|
|
1160
|
+
function validateIsoSince(raw, flagName = "--since") {
|
|
1161
1161
|
if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
|
|
1162
|
-
return
|
|
1162
|
+
return `${flagName} must be a parseable ISO-8601 calendar timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(raw)).slice(0, 80)}`;
|
|
1163
1163
|
}
|
|
1164
1164
|
return null;
|
|
1165
1165
|
}
|
|
@@ -1207,7 +1207,7 @@ function detectVexShape(doc) {
|
|
|
1207
1207
|
// OpenVEX: @context starts with https://openvex.dev AND statements[]
|
|
1208
1208
|
const ctx = doc["@context"];
|
|
1209
1209
|
const ctxStr = Array.isArray(ctx) ? ctx[0] : ctx;
|
|
1210
|
-
if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev") && Array.isArray(doc.statements)) {
|
|
1210
|
+
if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev/") && Array.isArray(doc.statements)) {
|
|
1211
1211
|
return { ok: true, detected: "openvex", top_level_keys: keys };
|
|
1212
1212
|
}
|
|
1213
1213
|
// Common false-positive shapes — give the operator a hint.
|
|
@@ -1399,13 +1399,20 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1399
1399
|
);
|
|
1400
1400
|
process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN = "1";
|
|
1401
1401
|
}
|
|
1402
|
-
if (args["session-id"]) {
|
|
1402
|
+
if (args["session-id"] !== undefined) {
|
|
1403
1403
|
// --session-id is a filesystem path component (resolves to
|
|
1404
1404
|
// .exceptd/attestations/<id>/attestation.json). Operator-supplied input
|
|
1405
1405
|
// with `..` or path separators escapes the attestation root. Route
|
|
1406
1406
|
// through the shared validateIdComponent('session') helper so the regex
|
|
1407
1407
|
// + all-dots refusal stay aligned with persistAttestation /
|
|
1408
1408
|
// validateSessionIdForRead.
|
|
1409
|
+
//
|
|
1410
|
+
// Presence-gated (`!== undefined`), not truthy-gated: `--session-id ""`
|
|
1411
|
+
// / `--session-id=` carry an explicit empty value the operator meant to
|
|
1412
|
+
// pin. A truthy gate skips the validator for "", silently substituting a
|
|
1413
|
+
// random id and discarding the operator's intent. validateIdComponent
|
|
1414
|
+
// rejects "" with "must not be empty", matching the --operator empty
|
|
1415
|
+
// refusal below.
|
|
1409
1416
|
const sid = args["session-id"];
|
|
1410
1417
|
const r = validateIdComponent(sid, "session");
|
|
1411
1418
|
if (!r.ok) {
|
|
@@ -2497,6 +2504,11 @@ async function cmdCollect(runner, args, runOpts, pretty) {
|
|
|
2497
2504
|
// --cwd <path> overrides process.cwd(). Validated as an existing
|
|
2498
2505
|
// directory; non-existent / non-directory cwd is operator error.
|
|
2499
2506
|
let cwd = process.cwd();
|
|
2507
|
+
// An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
|
|
2508
|
+
// scan process.cwd() — the wrong directory — reported as a successful run.
|
|
2509
|
+
if (args.cwd === "") {
|
|
2510
|
+
return emitError(`collect: --cwd was given an empty value; pass an existing directory path`, { verb: "collect", playbook_id: playbookId }, pretty);
|
|
2511
|
+
}
|
|
2500
2512
|
if (args.cwd) {
|
|
2501
2513
|
const resolved = path.resolve(String(args.cwd));
|
|
2502
2514
|
let stat;
|
|
@@ -3358,6 +3370,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3358
3370
|
// first, then falls back to a strict isTTY===false check only on Windows
|
|
3359
3371
|
// (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
|
|
3360
3372
|
// isTTY === false for genuine piped input, so that path still works.
|
|
3373
|
+
// An explicit empty value (`--evidence ""`) is operator error: it would
|
|
3374
|
+
// otherwise be falsy and silently produce a no-evidence "not_detected" run at
|
|
3375
|
+
// exit 0, masking the fact that the intended evidence never loaded.
|
|
3376
|
+
if (args.evidence === "") {
|
|
3377
|
+
return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run" }, pretty);
|
|
3378
|
+
}
|
|
3361
3379
|
const autoStdin = !args.evidence && hasReadableStdin();
|
|
3362
3380
|
if (autoStdin) {
|
|
3363
3381
|
args.evidence = "-";
|
|
@@ -4168,6 +4186,20 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4168
4186
|
runOpts.session_id = sessionId;
|
|
4169
4187
|
|
|
4170
4188
|
let bundle = {};
|
|
4189
|
+
// An explicit empty value (--evidence "" / --evidence= / an unset shell
|
|
4190
|
+
// variable) is falsy, so the truthiness-gated reads below would skip
|
|
4191
|
+
// entirely, the bundle would stay {}, every playbook would run with no
|
|
4192
|
+
// evidence, and the contract would report a clean not_detected at exit 0 —
|
|
4193
|
+
// a false-clean that hides the fact the operator's intended evidence never
|
|
4194
|
+
// loaded. Mirror the single-playbook run / ci empty-value guards: refuse
|
|
4195
|
+
// the empty value loudly rather than running a vacuous contract. Presence,
|
|
4196
|
+
// not truthiness, is the test.
|
|
4197
|
+
if (args.evidence === "") {
|
|
4198
|
+
return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run", flag: "evidence" }, pretty);
|
|
4199
|
+
}
|
|
4200
|
+
if (args["evidence-dir"] === "") {
|
|
4201
|
+
return emitError("run: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "run", flag: "evidence-dir" }, pretty);
|
|
4202
|
+
}
|
|
4171
4203
|
if (args.evidence) {
|
|
4172
4204
|
try { bundle = readEvidence(args.evidence); } catch (e) {
|
|
4173
4205
|
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
@@ -4176,11 +4208,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4176
4208
|
// --evidence-dir <dir>: each <playbook-id>.json under the directory is read
|
|
4177
4209
|
// as that playbook's submission. Lets operators wire up one cron job that
|
|
4178
4210
|
// collects per-playbook evidence into a directory, then runs the whole
|
|
4179
|
-
// contract in one pass.
|
|
4211
|
+
// contract in one pass. The empty-string form is already refused above; the
|
|
4212
|
+
// truthy gate here only ever sees a non-empty directory path.
|
|
4180
4213
|
if (args["evidence-dir"]) {
|
|
4181
4214
|
const dir = args["evidence-dir"];
|
|
4182
|
-
if (typeof dir !== "string"
|
|
4183
|
-
return emitError("run: --evidence-dir must be a
|
|
4215
|
+
if (typeof dir !== "string") {
|
|
4216
|
+
return emitError("run: --evidence-dir must be a string.", null, pretty);
|
|
4184
4217
|
}
|
|
4185
4218
|
if (!fs.existsSync(dir)) {
|
|
4186
4219
|
return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
@@ -4951,7 +4984,11 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
4951
4984
|
// Gate on the parsed kind so a renamed file cannot smuggle a replay
|
|
4952
4985
|
// record into the listing.
|
|
4953
4986
|
if (j && j.kind === "replay") continue;
|
|
4954
|
-
|
|
4987
|
+
// Filter on an explicitly-supplied playbook id. `!= null` (not a
|
|
4988
|
+
// truthiness check) so a future caller that threads an empty-string
|
|
4989
|
+
// id can't silently disable the filter and widen the match to every
|
|
4990
|
+
// playbook; the only legitimate "no filter" is `null`/`undefined`.
|
|
4991
|
+
if (opts.playbookId != null && j.playbook_id !== opts.playbookId) continue;
|
|
4955
4992
|
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
4956
4993
|
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
4957
4994
|
candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
|
|
@@ -5154,12 +5191,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5154
5191
|
pretty,
|
|
5155
5192
|
);
|
|
5156
5193
|
}
|
|
5157
|
-
const isoErr = validateIsoSince(cutoffRaw);
|
|
5194
|
+
const isoErr = validateIsoSince(cutoffRaw, "--all-older-than");
|
|
5158
5195
|
if (isoErr) return emitError(`attest prune: ${isoErr}`, { verb: "attest prune" }, pretty);
|
|
5159
5196
|
const cutoffMs = Date.parse(cutoffRaw);
|
|
5160
5197
|
const dryRun = !!args["dry-run"];
|
|
5161
5198
|
|
|
5162
|
-
|
|
5199
|
+
// Canonicalize before dedup. A plain Set over the two root strings only
|
|
5200
|
+
// collapses byte-identical paths, so when the default root and the cwd-
|
|
5201
|
+
// relative root resolve to the SAME directory via different strings (e.g. a
|
|
5202
|
+
// relative EXCEPTD_HOME like `.exceptd`, or the home-mkdir-fail fallback),
|
|
5203
|
+
// both survive and every session under that dir is scanned twice — inflating
|
|
5204
|
+
// scanned/kept/pruned_count and double-listing each session in the preview.
|
|
5205
|
+
// realpathSync resolves symlinks + makes absolute for an existing dir; for a
|
|
5206
|
+
// not-yet-created root it throws, so fall back to path.resolve (absolute +
|
|
5207
|
+
// normalized). Mirrors the realpath confinement used at delete time below.
|
|
5208
|
+
const canonicalRoot = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
5209
|
+
const roots = [];
|
|
5210
|
+
const seenRoots = new Set();
|
|
5211
|
+
for (const r of [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")]) {
|
|
5212
|
+
const c = canonicalRoot(r);
|
|
5213
|
+
if (seenRoots.has(c)) continue;
|
|
5214
|
+
seenRoots.add(c);
|
|
5215
|
+
roots.push(c);
|
|
5216
|
+
}
|
|
5163
5217
|
const pruned = [];
|
|
5164
5218
|
let kept = 0;
|
|
5165
5219
|
let scanned = 0;
|
|
@@ -5174,27 +5228,52 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5174
5228
|
// attestation. A session with no parseable captured_at is left alone
|
|
5175
5229
|
// (never delete something we can't date).
|
|
5176
5230
|
let captured = null;
|
|
5231
|
+
let replayFallback = null;
|
|
5177
5232
|
try {
|
|
5178
5233
|
for (const f of fs.readdirSync(sdir)) {
|
|
5179
5234
|
if (!f.endsWith(".json") || f.endsWith(".sig")) continue;
|
|
5180
5235
|
let j; try { j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8")); } catch { continue; }
|
|
5181
|
-
if (!j
|
|
5236
|
+
if (!j) continue;
|
|
5237
|
+
if (j.kind === "replay") {
|
|
5238
|
+
// A session holding only replay records (its attestation was
|
|
5239
|
+
// removed) would otherwise be undateable and never GC'd, so the
|
|
5240
|
+
// store grows without bound. Fall back to the newest replay
|
|
5241
|
+
// timestamp so prune can still age such a session out.
|
|
5242
|
+
if (typeof j.replayed_at === "string" && (!replayFallback || j.replayed_at > replayFallback)) replayFallback = j.replayed_at;
|
|
5243
|
+
continue;
|
|
5244
|
+
}
|
|
5182
5245
|
if (typeof j.captured_at === "string" && (!captured || j.captured_at > captured)) captured = j.captured_at;
|
|
5183
5246
|
}
|
|
5184
5247
|
} catch { continue; }
|
|
5185
|
-
|
|
5248
|
+
// Prefer the signed attestation's captured_at; fall back to the newest
|
|
5249
|
+
// replay timestamp for replay-only sessions.
|
|
5250
|
+
const dateStr = captured || replayFallback;
|
|
5251
|
+
const ts = dateStr ? Date.parse(dateStr) : NaN;
|
|
5186
5252
|
if (!Number.isFinite(ts)) { kept++; continue; }
|
|
5187
5253
|
if (ts < cutoffMs) {
|
|
5188
|
-
|
|
5254
|
+
// Confinement: resolve and confirm sdir is a direct child of root
|
|
5255
|
+
// before it can be deleted, so a crafted session name can't escape the
|
|
5256
|
+
// root. Evaluate this in BOTH modes so the dry-run preview lists exactly
|
|
5257
|
+
// the set a real run will remove — a session the real run would refuse
|
|
5258
|
+
// (realpath escapes the root, or realpathSync throws) must not show up
|
|
5259
|
+
// as [would-delete]. realDir is reused for the rmSync below so the
|
|
5260
|
+
// delete and the gate operate on the same canonical path (no TOCTOU
|
|
5261
|
+
// between the check and the removal).
|
|
5262
|
+
let realDir = null;
|
|
5263
|
+
try {
|
|
5264
|
+
const realRoot = fs.realpathSync(root);
|
|
5265
|
+
const candidate = fs.realpathSync(sdir);
|
|
5266
|
+
if (path.dirname(candidate) === realRoot) realDir = candidate;
|
|
5267
|
+
} catch { /* unresolvable -> not deletable */ }
|
|
5268
|
+
if (realDir === null) { kept++; continue; }
|
|
5189
5269
|
if (!dryRun) {
|
|
5190
|
-
//
|
|
5191
|
-
//
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
if (path.dirname(realDir) === realRoot) fs.rmSync(realDir, { recursive: true, force: true });
|
|
5196
|
-
} catch { /* skip undeletable */ }
|
|
5270
|
+
// Real run: count the session as pruned only after the delete
|
|
5271
|
+
// succeeds, so pruned_count is a post-condition (sessions actually
|
|
5272
|
+
// removed from disk), never a candidate tally.
|
|
5273
|
+
try { fs.rmSync(realDir, { recursive: true, force: true }); }
|
|
5274
|
+
catch { kept++; continue; /* skip undeletable */ }
|
|
5197
5275
|
}
|
|
5276
|
+
pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
|
|
5198
5277
|
} else {
|
|
5199
5278
|
kept++;
|
|
5200
5279
|
}
|
|
@@ -5209,13 +5288,13 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5209
5288
|
scanned,
|
|
5210
5289
|
pruned_count: pruned.length,
|
|
5211
5290
|
kept,
|
|
5212
|
-
pruned: pruned.map(p => ({ session_id: p.session_id, captured_at: p.captured_at })),
|
|
5291
|
+
pruned: pruned.map(p => ({ session_id: p.session_id, captured_at: p.captured_at, replayed_at: p.replayed_at })),
|
|
5213
5292
|
roots_searched: roots,
|
|
5214
5293
|
}, pretty, (obj) => {
|
|
5215
5294
|
const lines = [];
|
|
5216
5295
|
lines.push(`attest prune${obj.dry_run ? " (DRY-RUN)" : ""}: cutoff ${obj.cutoff}`);
|
|
5217
5296
|
lines.push(` scanned ${obj.scanned} session(s) | ${obj.dry_run ? "would prune" : "pruned"} ${obj.pruned_count} | kept ${obj.kept}`);
|
|
5218
|
-
for (const p of obj.pruned.slice(0, 20)) lines.push(` ${obj.dry_run ? "[would-delete]" : "[deleted]"} ${p.session_id} (${(p.captured_at || "").slice(0, 19)})`);
|
|
5297
|
+
for (const p of obj.pruned.slice(0, 20)) lines.push(` ${obj.dry_run ? "[would-delete]" : "[deleted]"} ${p.session_id} (${((p.captured_at || p.replayed_at) || "").slice(0, 19)})`);
|
|
5219
5298
|
if (obj.pruned_count > 20) lines.push(` … ${obj.pruned_count - 20} more`);
|
|
5220
5299
|
if (obj.dry_run && obj.pruned_count > 0) lines.push(` → re-run without --dry-run to delete.`);
|
|
5221
5300
|
return lines.join("\n");
|
|
@@ -5234,13 +5313,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5234
5313
|
const sinceErr = validateIsoSince(args.since);
|
|
5235
5314
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
5236
5315
|
}
|
|
5316
|
+
// Normalize --playbook (registered `multi:`, so a single value arrives as a
|
|
5317
|
+
// one-element array) and refuse an empty value. `--playbook ""` would
|
|
5318
|
+
// otherwise unwrap to "" and slip past walkAttestationDir's truthy filter
|
|
5319
|
+
// guard — silently widening --latest to the newest attestation across ALL
|
|
5320
|
+
// playbooks rather than the requested one. Refuse explicitly, the same way
|
|
5321
|
+
// --since refuses a malformed value above, so the operator sees the bad
|
|
5322
|
+
// input instead of an unintended cross-playbook match.
|
|
5323
|
+
let playbookFilter = null;
|
|
5324
|
+
if (args.playbook != null) {
|
|
5325
|
+
playbookFilter = Array.isArray(args.playbook) ? args.playbook[0] : args.playbook;
|
|
5326
|
+
if (typeof playbookFilter !== "string" || playbookFilter === "") {
|
|
5327
|
+
return emitError("reattest: --playbook was given an empty value. Pass a playbook id (e.g. --playbook kernel) or omit --playbook to match across all playbooks.", { verb: "reattest", flag: "playbook" }, pretty);
|
|
5328
|
+
}
|
|
5329
|
+
}
|
|
5237
5330
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
5238
5331
|
// without requiring the operator to know the session-id.
|
|
5239
5332
|
let sessionId = args._[0];
|
|
5240
5333
|
let attFile = null;
|
|
5241
5334
|
if (!sessionId && args.latest) {
|
|
5242
5335
|
const found = findLatestAttestation({
|
|
5243
|
-
playbookId:
|
|
5336
|
+
playbookId: playbookFilter,
|
|
5244
5337
|
since: args.since || null,
|
|
5245
5338
|
});
|
|
5246
5339
|
if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
|
|
@@ -5753,6 +5846,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5753
5846
|
// comparison. Without --against, replays current state against prior
|
|
5754
5847
|
// session (= reattest). With --against, compares two sessions A vs B
|
|
5755
5848
|
// by evidence_hash + artifact-level field diff.
|
|
5849
|
+
//
|
|
5850
|
+
// An empty `--against ""` / `--against=` parses to the empty string, which
|
|
5851
|
+
// is falsy — without this guard it would skip the explicit two-session
|
|
5852
|
+
// branch and silently fall through to the auto-prior path, comparing
|
|
5853
|
+
// against a DIFFERENT baseline than the operator named (a `--against
|
|
5854
|
+
// "$VAR"` that expanded to empty is the common footgun). REQUIRES_VALUE
|
|
5855
|
+
// only catches the value-less `--against` (parsed as `true`), not the
|
|
5856
|
+
// empty-string form. Refuse explicitly so the dropped target is signalled
|
|
5857
|
+
// rather than swapped under the operator.
|
|
5858
|
+
if (args.against === "") {
|
|
5859
|
+
return emitError(
|
|
5860
|
+
'attest diff: --against was given an empty value; pass a session-id, or omit --against to diff against the most-recent prior.',
|
|
5861
|
+
{ verb: "attest diff", flag: "against" },
|
|
5862
|
+
pretty
|
|
5863
|
+
);
|
|
5864
|
+
}
|
|
5756
5865
|
if (args.against) {
|
|
5757
5866
|
// Validate the --against id with the same gate as the primary sid, so a
|
|
5758
5867
|
// traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
|
|
@@ -5858,14 +5967,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5858
5967
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
5859
5968
|
// for flat submissions; the diff returned all zeros even when
|
|
5860
5969
|
// artifacts were present in observations.
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
)
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5970
|
+
// The catalog stub stands in for an empty side ONLY when BOTH sides
|
|
5971
|
+
// are empty — substituting it for one empty side while the peer passes
|
|
5972
|
+
// through its real keys manufactures phantom drift (every catalog id
|
|
5973
|
+
// the populated side did not submit reads as added/changed).
|
|
5974
|
+
...(() => {
|
|
5975
|
+
const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
|
|
5976
|
+
return {
|
|
5977
|
+
artifact_diff: diffArtifacts(
|
|
5978
|
+
normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
|
|
5979
|
+
normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty)
|
|
5980
|
+
),
|
|
5981
|
+
signal_override_diff: diffSignalOverrides(
|
|
5982
|
+
normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
|
|
5983
|
+
normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty)
|
|
5984
|
+
),
|
|
5985
|
+
};
|
|
5986
|
+
})(),
|
|
5869
5987
|
}, pretty, renderAttestDiff);
|
|
5870
5988
|
return;
|
|
5871
5989
|
}
|
|
@@ -5942,14 +6060,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5942
6060
|
sidecar_verify: aSidecarVerify,
|
|
5943
6061
|
a_sidecar_verify: aSidecarVerify,
|
|
5944
6062
|
b_sidecar_verify: bSidecarVerify,
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
)
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
6063
|
+
// Catalog stub stands in for an empty side only when BOTH sides are
|
|
6064
|
+
// empty (same peer-symmetric gate as the --against branch); real-vs-empty
|
|
6065
|
+
// diffs the populated side's keys against {}, not against the full catalog.
|
|
6066
|
+
...(() => {
|
|
6067
|
+
const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
|
|
6068
|
+
return {
|
|
6069
|
+
artifact_diff: diffArtifacts(
|
|
6070
|
+
normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6071
|
+
normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty),
|
|
6072
|
+
),
|
|
6073
|
+
signal_override_diff: diffSignalOverrides(
|
|
6074
|
+
normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6075
|
+
normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty),
|
|
6076
|
+
),
|
|
6077
|
+
};
|
|
6078
|
+
})(),
|
|
5953
6079
|
}, pretty, renderAttestDiff);
|
|
5954
6080
|
return;
|
|
5955
6081
|
}
|
|
@@ -6220,9 +6346,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6220
6346
|
run_opts: a.run_opts,
|
|
6221
6347
|
artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
|
|
6222
6348
|
.map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
|
|
6223
|
-
signal_overrides
|
|
6349
|
+
// signal_overrides are operator-controllable: the contract canonicalizes
|
|
6350
|
+
// hit/miss/inconclusive verdicts but does NOT reject free-form values
|
|
6351
|
+
// (an unrecognized value surfaces a signal_override_unrecognized runtime
|
|
6352
|
+
// error yet is still stored verbatim in the submission), and the sibling
|
|
6353
|
+
// `<id>__fp_checks` keys carry arbitrary operator attestation maps. Under
|
|
6354
|
+
// a bundle labelled "redacted ... suitable for audit submission" the only
|
|
6355
|
+
// audit-meaningful content is the indicator verdict itself, so keep an
|
|
6356
|
+
// exact hit/miss/inconclusive value and replace everything else (free-form
|
|
6357
|
+
// strings, captured-data values, __fp_checks objects) with "[redacted]".
|
|
6358
|
+
// Apply the same keyname denylist as signals_redacted so an obviously-
|
|
6359
|
+
// sensitive key can't ride through under this field either. Matches the
|
|
6360
|
+
// signal-value-redaction contract asserted in tests/cli-coverage.js.
|
|
6361
|
+
signal_overrides: Object.fromEntries(Object.entries((a.submission && a.submission.signal_overrides) || {})
|
|
6362
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6363
|
+
.map(([k, v]) => [k, (v === "hit" || v === "miss" || v === "inconclusive") ? v : "[redacted]"])),
|
|
6364
|
+
// Redact the VALUES, not just drop obviously-sensitive keys: a submitted
|
|
6365
|
+
// signal value can hold operator data (e.g. a captured credential string),
|
|
6366
|
+
// and this field is labelled "redacted". The keyname denylist still drops
|
|
6367
|
+
// the obviously-sensitive keys entirely; every retained key keeps only a
|
|
6368
|
+
// "[redacted]" placeholder value, matching artifacts_redacted above.
|
|
6224
6369
|
signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
|
|
6225
|
-
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6370
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6371
|
+
.map(([k]) => [k, "[redacted]"])),
|
|
6226
6372
|
precondition_checks: (a.submission && a.submission.precondition_checks) || {},
|
|
6227
6373
|
}));
|
|
6228
6374
|
|
|
@@ -6244,7 +6390,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6244
6390
|
verb: "attest export",
|
|
6245
6391
|
session_id: sessionId,
|
|
6246
6392
|
exported_at: new Date().toISOString(),
|
|
6247
|
-
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides
|
|
6393
|
+
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides reduced to hit/miss/inconclusive verdicts (free-form values redacted); precondition_checks + evidence_hash + signature preserved.",
|
|
6248
6394
|
attestations: redacted,
|
|
6249
6395
|
}, pretty);
|
|
6250
6396
|
}
|
|
@@ -6284,9 +6430,25 @@ function _playbookSignalCatalog(runner, playbookId) {
|
|
|
6284
6430
|
return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
|
|
6285
6431
|
} catch { return null; }
|
|
6286
6432
|
}
|
|
6287
|
-
|
|
6433
|
+
// A submission carries real operator data for a diff when it supplied
|
|
6434
|
+
// artifacts, signal_overrides, OR observations. The empty case ({} or
|
|
6435
|
+
// {observations:{}}) is "no operator data was supplied." Diff symmetry hinges
|
|
6436
|
+
// on this predicate: the playbook catalog stub may only stand in for an empty
|
|
6437
|
+
// side when BOTH sides are empty (so the count reflects "N catalog ids,
|
|
6438
|
+
// uniformly empty on both sides"). Substituting the full catalog for one empty
|
|
6439
|
+
// side while the peer passes through its real keys manufactures phantom drift —
|
|
6440
|
+
// every catalog id the populated side did not submit shows up as "added"
|
|
6441
|
+
// (artifacts) or "changed" (signals). See callers for the bothEmpty gate.
|
|
6442
|
+
function submissionHasData(submission) {
|
|
6443
|
+
if (!submission || typeof submission !== "object") return false;
|
|
6444
|
+
const nonEmpty = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
|
|
6445
|
+
return nonEmpty(submission.artifacts)
|
|
6446
|
+
|| nonEmpty(submission.signal_overrides)
|
|
6447
|
+
|| nonEmpty(submission.observations);
|
|
6448
|
+
}
|
|
6449
|
+
function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6288
6450
|
if (!submission || typeof submission !== "object") {
|
|
6289
|
-
return _playbookArtifactCatalog(runner, playbookId) || {};
|
|
6451
|
+
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
6290
6452
|
}
|
|
6291
6453
|
if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
|
|
6292
6454
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6308,15 +6470,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
|
|
|
6308
6470
|
}
|
|
6309
6471
|
return out;
|
|
6310
6472
|
}
|
|
6311
|
-
//
|
|
6312
|
-
//
|
|
6313
|
-
//
|
|
6314
|
-
//
|
|
6315
|
-
|
|
6473
|
+
// Empty submission ({} or {observations:{}}). The catalog stub may only
|
|
6474
|
+
// stand in when the PEER side is also empty (applyEmptyFallback). When the
|
|
6475
|
+
// peer carried real artifacts, return an empty map so the populated side's
|
|
6476
|
+
// keys diff against nothing — yielding genuine added/removed instead of one
|
|
6477
|
+
// fabricated "added" per catalog id the operator never submitted.
|
|
6478
|
+
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
6316
6479
|
}
|
|
6317
|
-
function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
6480
|
+
function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6318
6481
|
if (!submission || typeof submission !== "object") {
|
|
6319
|
-
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
6482
|
+
return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
|
|
6320
6483
|
}
|
|
6321
6484
|
if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
|
|
6322
6485
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6335,7 +6498,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
6335
6498
|
}
|
|
6336
6499
|
return out;
|
|
6337
6500
|
}
|
|
6338
|
-
|
|
6501
|
+
// Empty submission — same peer-symmetric gate as normalizedArtifacts: only
|
|
6502
|
+
// stand in the inconclusive catalog stub when the peer is also empty, so a
|
|
6503
|
+
// real-vs-empty signal diff reports only the genuinely-differing indicators.
|
|
6504
|
+
return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
|
|
6339
6505
|
}
|
|
6340
6506
|
|
|
6341
6507
|
/**
|
|
@@ -6386,14 +6552,14 @@ function diffArtifacts(a, b) {
|
|
|
6386
6552
|
for (const id of allIds) {
|
|
6387
6553
|
const av = a[id], bv = b[id];
|
|
6388
6554
|
if (!av && bv) {
|
|
6389
|
-
out.added.push({ id, captured: !!bv.captured, value_preview:
|
|
6555
|
+
out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
|
|
6390
6556
|
} else if (av && !bv) {
|
|
6391
|
-
out.removed.push({ id, captured: !!av.captured, value_preview:
|
|
6557
|
+
out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
|
|
6392
6558
|
} else if (av && bv && artifactsDiffer(av, bv)) {
|
|
6393
6559
|
out.changed.push({
|
|
6394
6560
|
id,
|
|
6395
6561
|
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
6396
|
-
a_value_preview:
|
|
6562
|
+
a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
|
|
6397
6563
|
});
|
|
6398
6564
|
} else if (av && bv) {
|
|
6399
6565
|
// v0.11.8 (#102): both sides have the entry AND they're identical →
|
|
@@ -6426,6 +6592,23 @@ function previewValue(v) {
|
|
|
6426
6592
|
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
6427
6593
|
}
|
|
6428
6594
|
|
|
6595
|
+
// Preview the evidence an artifact carries for the diff output. `.value` is the
|
|
6596
|
+
// canonical carrier, but observations legitimately store their secret/path/match
|
|
6597
|
+
// under other keys (path, matched, reason, or a custom key). When `.value` is
|
|
6598
|
+
// absent, fall back to a preview of the remaining evidence-bearing keys (every
|
|
6599
|
+
// key except the bookkeeping `captured`/`captured_at` flags) so non-`value`
|
|
6600
|
+
// carriers still render instead of collapsing to a null preview — which hid the
|
|
6601
|
+
// actual differing content even when the per-field equality compare correctly
|
|
6602
|
+
// flagged the artifact as changed.
|
|
6603
|
+
function artifactPreview(art) {
|
|
6604
|
+
if (art === null || typeof art !== "object" || Array.isArray(art)) return previewValue(art);
|
|
6605
|
+
if (art.value !== undefined && art.value !== null) return previewValue(art.value);
|
|
6606
|
+
const { captured, captured_at, _captured_at, value, ...evidence } = art;
|
|
6607
|
+
const keys = Object.keys(evidence);
|
|
6608
|
+
if (keys.length === 0) return null;
|
|
6609
|
+
return previewValue(evidence);
|
|
6610
|
+
}
|
|
6611
|
+
|
|
6429
6612
|
// ---------------------------------------------------------------------------
|
|
6430
6613
|
// v0.11.0: cmdDiscover — context-aware playbook recommender.
|
|
6431
6614
|
// Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
|
|
@@ -6436,6 +6619,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
|
|
|
6436
6619
|
// process cwd. Pre-fix it was silently ignored — recommendations were
|
|
6437
6620
|
// computed for the wrong directory with no signal. Validated like collect.
|
|
6438
6621
|
let cwd = process.cwd();
|
|
6622
|
+
// An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
|
|
6623
|
+
// scan process.cwd() — the wrong directory — reported as a successful run.
|
|
6624
|
+
if (args.cwd === "") {
|
|
6625
|
+
return emitError(`discover: --cwd was given an empty value; pass an existing directory path`, { verb: "discover" }, pretty);
|
|
6626
|
+
}
|
|
6439
6627
|
if (args.cwd) {
|
|
6440
6628
|
const resolved = path.resolve(String(args.cwd));
|
|
6441
6629
|
let stat;
|
|
@@ -7923,6 +8111,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
7923
8111
|
if (!playbookId) {
|
|
7924
8112
|
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
7925
8113
|
}
|
|
8114
|
+
// An explicit empty value (`--evidence ""`) is operator error, same as `run`.
|
|
8115
|
+
// The `--no-stream` path tests `args.evidence` for truthiness, so `""` fell
|
|
8116
|
+
// through to the stdin branch and — with empty/closed stdin — ran an empty
|
|
8117
|
+
// submission to ok:true at exit 0, masking that the intended evidence never
|
|
8118
|
+
// loaded. Reject it here so both stream and no-stream entry behave like `run`.
|
|
8119
|
+
if (args.evidence === "") {
|
|
8120
|
+
return emitError("ai-run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence to read evidence from the stream", { verb: "ai-run" }, pretty);
|
|
8121
|
+
}
|
|
7926
8122
|
if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
|
|
7927
8123
|
let pb;
|
|
7928
8124
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
@@ -8672,6 +8868,27 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8672
8868
|
pretty,
|
|
8673
8869
|
);
|
|
8674
8870
|
}
|
|
8871
|
+
// An explicit empty value (`--evidence ""` / `--evidence=` / an unset shell
|
|
8872
|
+
// variable) is falsy, so the truthiness-gated evidence reads below skip
|
|
8873
|
+
// entirely, the bundle stays {}, every playbook runs with no evidence, and
|
|
8874
|
+
// the gate reports a clean PASS at exit 0 — a false-green that hides the fact
|
|
8875
|
+
// the operator's intended evidence never loaded. Mirror the run/collect/
|
|
8876
|
+
// discover empty-value guards: refuse the empty value loudly rather than
|
|
8877
|
+
// running a vacuous gate.
|
|
8878
|
+
if (args.evidence === "") {
|
|
8879
|
+
return emitError(
|
|
8880
|
+
"ci: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run",
|
|
8881
|
+
{ verb: "ci", flag: "evidence" },
|
|
8882
|
+
pretty,
|
|
8883
|
+
);
|
|
8884
|
+
}
|
|
8885
|
+
if (args["evidence-dir"] === "") {
|
|
8886
|
+
return emitError(
|
|
8887
|
+
"ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir",
|
|
8888
|
+
{ verb: "ci", flag: "evidence-dir" },
|
|
8889
|
+
pretty,
|
|
8890
|
+
);
|
|
8891
|
+
}
|
|
8675
8892
|
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
8676
8893
|
|
|
8677
8894
|
// v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over
|