@blamejs/exceptd-skills 0.18.6 → 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 +20 -0
- package/bin/exceptd.js +261 -56
- 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,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 0.18.6 — 2026-06-14
|
|
4
24
|
|
|
5
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.
|
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;
|
|
@@ -5197,16 +5251,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5197
5251
|
const ts = dateStr ? Date.parse(dateStr) : NaN;
|
|
5198
5252
|
if (!Number.isFinite(ts)) { kept++; continue; }
|
|
5199
5253
|
if (ts < cutoffMs) {
|
|
5200
|
-
|
|
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; }
|
|
5201
5269
|
if (!dryRun) {
|
|
5202
|
-
//
|
|
5203
|
-
//
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
if (path.dirname(realDir) === realRoot) fs.rmSync(realDir, { recursive: true, force: true });
|
|
5208
|
-
} 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 */ }
|
|
5209
5275
|
}
|
|
5276
|
+
pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
|
|
5210
5277
|
} else {
|
|
5211
5278
|
kept++;
|
|
5212
5279
|
}
|
|
@@ -5246,13 +5313,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5246
5313
|
const sinceErr = validateIsoSince(args.since);
|
|
5247
5314
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
5248
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
|
+
}
|
|
5249
5330
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
5250
5331
|
// without requiring the operator to know the session-id.
|
|
5251
5332
|
let sessionId = args._[0];
|
|
5252
5333
|
let attFile = null;
|
|
5253
5334
|
if (!sessionId && args.latest) {
|
|
5254
5335
|
const found = findLatestAttestation({
|
|
5255
|
-
playbookId:
|
|
5336
|
+
playbookId: playbookFilter,
|
|
5256
5337
|
since: args.since || null,
|
|
5257
5338
|
});
|
|
5258
5339
|
if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
|
|
@@ -5765,6 +5846,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5765
5846
|
// comparison. Without --against, replays current state against prior
|
|
5766
5847
|
// session (= reattest). With --against, compares two sessions A vs B
|
|
5767
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
|
+
}
|
|
5768
5865
|
if (args.against) {
|
|
5769
5866
|
// Validate the --against id with the same gate as the primary sid, so a
|
|
5770
5867
|
// traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
|
|
@@ -5870,14 +5967,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5870
5967
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
5871
5968
|
// for flat submissions; the diff returned all zeros even when
|
|
5872
5969
|
// artifacts were present in observations.
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
)
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
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
|
+
})(),
|
|
5881
5987
|
}, pretty, renderAttestDiff);
|
|
5882
5988
|
return;
|
|
5883
5989
|
}
|
|
@@ -5954,14 +6060,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5954
6060
|
sidecar_verify: aSidecarVerify,
|
|
5955
6061
|
a_sidecar_verify: aSidecarVerify,
|
|
5956
6062
|
b_sidecar_verify: bSidecarVerify,
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
)
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
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
|
+
})(),
|
|
5965
6079
|
}, pretty, renderAttestDiff);
|
|
5966
6080
|
return;
|
|
5967
6081
|
}
|
|
@@ -6232,9 +6346,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6232
6346
|
run_opts: a.run_opts,
|
|
6233
6347
|
artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
|
|
6234
6348
|
.map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
|
|
6235
|
-
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.
|
|
6236
6369
|
signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
|
|
6237
|
-
.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]"])),
|
|
6238
6372
|
precondition_checks: (a.submission && a.submission.precondition_checks) || {},
|
|
6239
6373
|
}));
|
|
6240
6374
|
|
|
@@ -6256,7 +6390,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6256
6390
|
verb: "attest export",
|
|
6257
6391
|
session_id: sessionId,
|
|
6258
6392
|
exported_at: new Date().toISOString(),
|
|
6259
|
-
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.",
|
|
6260
6394
|
attestations: redacted,
|
|
6261
6395
|
}, pretty);
|
|
6262
6396
|
}
|
|
@@ -6296,9 +6430,25 @@ function _playbookSignalCatalog(runner, playbookId) {
|
|
|
6296
6430
|
return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
|
|
6297
6431
|
} catch { return null; }
|
|
6298
6432
|
}
|
|
6299
|
-
|
|
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) {
|
|
6300
6450
|
if (!submission || typeof submission !== "object") {
|
|
6301
|
-
return _playbookArtifactCatalog(runner, playbookId) || {};
|
|
6451
|
+
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
6302
6452
|
}
|
|
6303
6453
|
if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
|
|
6304
6454
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6320,15 +6470,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
|
|
|
6320
6470
|
}
|
|
6321
6471
|
return out;
|
|
6322
6472
|
}
|
|
6323
|
-
//
|
|
6324
|
-
//
|
|
6325
|
-
//
|
|
6326
|
-
//
|
|
6327
|
-
|
|
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) || {}) : {};
|
|
6328
6479
|
}
|
|
6329
|
-
function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
6480
|
+
function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6330
6481
|
if (!submission || typeof submission !== "object") {
|
|
6331
|
-
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
6482
|
+
return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
|
|
6332
6483
|
}
|
|
6333
6484
|
if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
|
|
6334
6485
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6347,7 +6498,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
6347
6498
|
}
|
|
6348
6499
|
return out;
|
|
6349
6500
|
}
|
|
6350
|
-
|
|
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) || {}) : {};
|
|
6351
6505
|
}
|
|
6352
6506
|
|
|
6353
6507
|
/**
|
|
@@ -6398,14 +6552,14 @@ function diffArtifacts(a, b) {
|
|
|
6398
6552
|
for (const id of allIds) {
|
|
6399
6553
|
const av = a[id], bv = b[id];
|
|
6400
6554
|
if (!av && bv) {
|
|
6401
|
-
out.added.push({ id, captured: !!bv.captured, value_preview:
|
|
6555
|
+
out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
|
|
6402
6556
|
} else if (av && !bv) {
|
|
6403
|
-
out.removed.push({ id, captured: !!av.captured, value_preview:
|
|
6557
|
+
out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
|
|
6404
6558
|
} else if (av && bv && artifactsDiffer(av, bv)) {
|
|
6405
6559
|
out.changed.push({
|
|
6406
6560
|
id,
|
|
6407
6561
|
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
6408
|
-
a_value_preview:
|
|
6562
|
+
a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
|
|
6409
6563
|
});
|
|
6410
6564
|
} else if (av && bv) {
|
|
6411
6565
|
// v0.11.8 (#102): both sides have the entry AND they're identical →
|
|
@@ -6438,6 +6592,23 @@ function previewValue(v) {
|
|
|
6438
6592
|
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
6439
6593
|
}
|
|
6440
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
|
+
|
|
6441
6612
|
// ---------------------------------------------------------------------------
|
|
6442
6613
|
// v0.11.0: cmdDiscover — context-aware playbook recommender.
|
|
6443
6614
|
// Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
|
|
@@ -6448,6 +6619,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
|
|
|
6448
6619
|
// process cwd. Pre-fix it was silently ignored — recommendations were
|
|
6449
6620
|
// computed for the wrong directory with no signal. Validated like collect.
|
|
6450
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
|
+
}
|
|
6451
6627
|
if (args.cwd) {
|
|
6452
6628
|
const resolved = path.resolve(String(args.cwd));
|
|
6453
6629
|
let stat;
|
|
@@ -7935,6 +8111,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
7935
8111
|
if (!playbookId) {
|
|
7936
8112
|
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
7937
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
|
+
}
|
|
7938
8122
|
if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
|
|
7939
8123
|
let pb;
|
|
7940
8124
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
@@ -8684,6 +8868,27 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8684
8868
|
pretty,
|
|
8685
8869
|
);
|
|
8686
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
|
+
}
|
|
8687
8892
|
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
8688
8893
|
|
|
8689
8894
|
// v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-20T16:35:33.578Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "6e8a5ac562c706b0142ad12b07a62982bb83d500870ca33a5d865bd17a16d23a",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
|
|
10
10
|
"data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
|
|
11
|
-
"data/cve-catalog.json": "
|
|
11
|
+
"data/cve-catalog.json": "06ca53e69071dfe94867c10717f3ff2962c50341defcaf050f1a96236b5be51c",
|
|
12
12
|
"data/cwe-catalog.json": "359263361fa52069e2856cc352d8f1c757d614ea840db6ef3ff5e696185ca220",
|
|
13
13
|
"data/d3fend-catalog.json": "349d14b2777342d38e5f8b0149a9ebd7703ee59f67a9efee200d1e19313088ac",
|
|
14
14
|
"data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
|
|
@@ -71,6 +71,25 @@
|
|
|
71
71
|
},
|
|
72
72
|
"skill_count": 51,
|
|
73
73
|
"catalog_count": 11,
|
|
74
|
+
"outputs": [
|
|
75
|
+
"activity-feed.json",
|
|
76
|
+
"catalog-summaries.json",
|
|
77
|
+
"chains.json",
|
|
78
|
+
"currency.json",
|
|
79
|
+
"did-ladders.json",
|
|
80
|
+
"frequency.json",
|
|
81
|
+
"handoff-dag.json",
|
|
82
|
+
"jurisdiction-clocks.json",
|
|
83
|
+
"jurisdiction-map.json",
|
|
84
|
+
"recipes.json",
|
|
85
|
+
"section-offsets.json",
|
|
86
|
+
"stale-content.json",
|
|
87
|
+
"summary-cards.json",
|
|
88
|
+
"theater-fingerprints.json",
|
|
89
|
+
"token-budget.json",
|
|
90
|
+
"trigger-table.json",
|
|
91
|
+
"xref.json"
|
|
92
|
+
],
|
|
74
93
|
"index_stats": {
|
|
75
94
|
"xref_entries": {
|
|
76
95
|
"cwe_refs": 53,
|