@blamejs/exceptd-skills 0.12.7 → 0.12.9
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/AGENTS.md +15 -1
- package/ARCHITECTURE.md +21 -5
- package/CHANGELOG.md +150 -0
- package/README.md +1 -1
- package/bin/exceptd.js +416 -69
- package/data/_indexes/_meta.json +44 -44
- package/data/_indexes/activity-feed.json +34 -34
- package/data/_indexes/catalog-summaries.json +9 -9
- package/data/_indexes/chains.json +249 -11
- package/data/_indexes/frequency.json +63 -5
- package/data/_indexes/jurisdiction-map.json +13 -3
- package/data/_indexes/section-offsets.json +1171 -1027
- package/data/_indexes/summary-cards.json +2 -2
- package/data/_indexes/token-budget.json +232 -152
- package/data/atlas-ttps.json +189 -1
- package/data/cve-catalog.json +34 -22
- package/data/cwe-catalog.json +290 -1
- package/data/d3fend-catalog.json +163 -1
- package/data/framework-control-gaps.json +243 -0
- package/data/playbooks/containers.json +23 -5
- package/data/playbooks/cred-stores.json +9 -9
- package/data/playbooks/crypto.json +8 -8
- package/data/playbooks/hardening.json +46 -10
- package/data/playbooks/library-author.json +16 -20
- package/data/playbooks/mcp.json +1 -0
- package/data/playbooks/runtime.json +7 -7
- package/data/playbooks/sbom.json +11 -11
- package/data/playbooks/secrets.json +4 -4
- package/data/rfc-references.json +144 -0
- package/lib/playbook-runner.js +119 -35
- package/lib/prefetch.js +27 -6
- package/lib/refresh-external.js +32 -9
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/manifest-snapshot.json +1 -1
- package/manifest.json +73 -73
- package/orchestrator/index.js +1 -1
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-sbom-currency.js +87 -0
- package/scripts/check-test-coverage.README.md +148 -0
- package/scripts/check-test-coverage.js +476 -0
- package/scripts/hooks/pre-commit.sh +19 -0
- package/scripts/predeploy.js +14 -30
- package/skills/age-gates-child-safety/skill.md +3 -0
- package/skills/ai-attack-surface/skill.md +29 -1
- package/skills/ai-c2-detection/skill.md +30 -1
- package/skills/ai-risk-management/skill.md +3 -0
- package/skills/api-security/skill.md +3 -0
- package/skills/attack-surface-pentest/skill.md +3 -0
- package/skills/cloud-security/skill.md +3 -0
- package/skills/compliance-theater/skill.md +6 -0
- package/skills/container-runtime-security/skill.md +3 -0
- package/skills/coordinated-vuln-disclosure/skill.md +8 -1
- package/skills/defensive-countermeasure-mapping/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +3 -0
- package/skills/email-security-anti-phishing/skill.md +9 -1
- package/skills/exploit-scoring/skill.md +6 -0
- package/skills/identity-assurance/skill.md +6 -1
- package/skills/incident-response-playbook/skill.md +8 -2
- package/skills/kernel-lpe-triage/skill.md +24 -4
- package/skills/mcp-agent-trust/skill.md +28 -1
- package/skills/mlops-security/skill.md +3 -0
- package/skills/ot-ics-security/skill.md +3 -0
- package/skills/policy-exception-gen/skill.md +6 -0
- package/skills/rag-pipeline-security/skill.md +30 -1
- package/skills/researcher/skill.md +6 -0
- package/skills/sector-energy/skill.md +3 -0
- package/skills/sector-federal-government/skill.md +3 -0
- package/skills/sector-financial/skill.md +3 -0
- package/skills/sector-healthcare/skill.md +3 -0
- package/skills/security-maturity-tiers/skill.md +25 -1
- package/skills/skill-update-loop/skill.md +38 -0
- package/skills/supply-chain-integrity/skill.md +3 -0
- package/skills/threat-model-currency/skill.md +4 -0
- package/skills/threat-modeling-methodology/skill.md +3 -0
- package/skills/webapp-security/skill.md +3 -0
- package/skills/zeroday-gap-learn/skill.md +6 -0
package/bin/exceptd.js
CHANGED
|
@@ -378,27 +378,29 @@ function main() {
|
|
|
378
378
|
process.exit(0);
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// v0.12.8: emit the deprecation banner BEFORE branching on PLAYBOOK_VERBS
|
|
382
|
+
// so that legacy aliases routed through STANDALONE_VERBS or the orchestrator
|
|
383
|
+
// (scan, dispatch, currency, verify, validate-cves, validate-rfcs,
|
|
384
|
+
// watchlist, prefetch, build-indexes) also surface the rename.
|
|
385
|
+
// Previously the banner only fired for PLAYBOOK_VERBS-resident aliases
|
|
386
|
+
// (plan, govern, direct, look, ingest, reattest, list-attestations).
|
|
387
|
+
if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
|
|
388
|
+
const ver = readPkgVersion();
|
|
389
|
+
const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
|
|
390
|
+
process.stderr.write(
|
|
391
|
+
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
|
|
392
|
+
(haveBrief
|
|
393
|
+
? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
|
|
394
|
+
: `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
|
|
395
|
+
`Legacy verbs remain functional through this release; they will be removed in v0.13. ` +
|
|
396
|
+
`Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
|
|
397
|
+
);
|
|
398
|
+
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
399
|
+
}
|
|
400
|
+
|
|
381
401
|
// Seven-phase playbook verbs run in-process — they emit JSON to stdout
|
|
382
402
|
// rather than dispatch to a script.
|
|
383
403
|
if (PLAYBOOK_VERBS.has(cmd)) {
|
|
384
|
-
// One-time deprecation banner per process when a legacy verb is invoked.
|
|
385
|
-
if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
|
|
386
|
-
// Mention the installed version explicitly so an operator on v0.10.x
|
|
387
|
-
// who reads "Prefer brief..." doesn't go looking for a verb that
|
|
388
|
-
// doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
|
|
389
|
-
// users see this with the explicit "upgrade to v0.11.0 first" note.
|
|
390
|
-
const ver = readPkgVersion();
|
|
391
|
-
const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
|
|
392
|
-
process.stderr.write(
|
|
393
|
-
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
|
|
394
|
-
(haveBrief
|
|
395
|
-
? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
|
|
396
|
-
: `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
|
|
397
|
-
`Legacy verbs remain functional through this release; they will be removed in v0.13. ` +
|
|
398
|
-
`Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
|
|
399
|
-
);
|
|
400
|
-
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
401
|
-
}
|
|
402
404
|
dispatchPlaybook(cmd, rest);
|
|
403
405
|
return;
|
|
404
406
|
}
|
|
@@ -579,7 +581,12 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
579
581
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
580
582
|
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
581
583
|
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
582
|
-
"json-stdout-only", "fix", "human", "json", "strict-preconditions"
|
|
584
|
+
"json-stdout-only", "fix", "human", "json", "strict-preconditions",
|
|
585
|
+
// v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
|
|
586
|
+
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
587
|
+
// were already accepted; explicit registration removes the silent
|
|
588
|
+
// "unknown bool flag" surface in parseArgs.
|
|
589
|
+
"shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs"],
|
|
583
590
|
multi: ["playbook", "format"],
|
|
584
591
|
});
|
|
585
592
|
// v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
|
|
@@ -701,17 +708,62 @@ function buildSkillToPlaybookHint(runner, wanted) {
|
|
|
701
708
|
if (matches.length > 0) {
|
|
702
709
|
return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
|
|
703
710
|
`To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
|
|
704
|
-
`Tip: \`exceptd
|
|
711
|
+
`Tip: \`exceptd brief --all\` lists all 13 playbooks; \`exceptd watch\` lists skills.`;
|
|
705
712
|
}
|
|
706
713
|
// No matching skill either — provide nearest-playbook suggestions.
|
|
707
|
-
|
|
714
|
+
// v0.12.9 (P3 #9 from production smoke): substring fallback first (cheap),
|
|
715
|
+
// then edit-distance for typos that don't substring-match (`secrt`,
|
|
716
|
+
// `kernl`, `cret-stores`). Without the second pass `run secrt` returned
|
|
717
|
+
// the generic "13 playbooks" message even though `secrets` is one edit
|
|
718
|
+
// away.
|
|
719
|
+
const subMatches = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
|
|
720
|
+
const fuzzyMatches = subMatches.length === 0 ? nearestByEditDistance(wanted, ids, 2).slice(0, 3) : [];
|
|
721
|
+
const near = subMatches.length ? subMatches : fuzzyMatches;
|
|
708
722
|
if (near.length > 0) {
|
|
709
|
-
return `Did you mean: ${near.join(", ")}? Run \`exceptd
|
|
723
|
+
return `Did you mean: ${near.join(", ")}? Run \`exceptd brief --all\` for the full list.`;
|
|
710
724
|
}
|
|
711
|
-
return `Run \`exceptd
|
|
725
|
+
return `Run \`exceptd brief --all\` to list the 13 playbooks.`;
|
|
712
726
|
} catch { return null; }
|
|
713
727
|
}
|
|
714
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Cheap Levenshtein distance, used to surface "Did you mean X?" suggestions
|
|
731
|
+
* for misspelled playbook ids in the `run <typo>` error path. Returns ids
|
|
732
|
+
* whose distance from `wanted` is ≤ `maxDistance`, sorted by closest first.
|
|
733
|
+
* Bounded by the candidate set size (13 playbooks), so the O(n*m) cost is
|
|
734
|
+
* negligible.
|
|
735
|
+
*/
|
|
736
|
+
function nearestByEditDistance(wanted, ids, maxDistance) {
|
|
737
|
+
if (!wanted || !Array.isArray(ids)) return [];
|
|
738
|
+
const w = String(wanted).toLowerCase();
|
|
739
|
+
const scored = [];
|
|
740
|
+
for (const id of ids) {
|
|
741
|
+
const d = editDistance(w, id.toLowerCase());
|
|
742
|
+
if (d <= maxDistance) scored.push({ id, d });
|
|
743
|
+
}
|
|
744
|
+
scored.sort((a, b) => a.d - b.d);
|
|
745
|
+
return scored.map(s => s.id);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function editDistance(a, b) {
|
|
749
|
+
if (a === b) return 0;
|
|
750
|
+
if (a.length === 0) return b.length;
|
|
751
|
+
if (b.length === 0) return a.length;
|
|
752
|
+
const prev = new Array(b.length + 1);
|
|
753
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
754
|
+
for (let i = 1; i <= a.length; i++) {
|
|
755
|
+
let cur = i;
|
|
756
|
+
for (let j = 1; j <= b.length; j++) {
|
|
757
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
758
|
+
const next = Math.min(prev[j] + 1, cur + 1, prev[j - 1] + cost);
|
|
759
|
+
prev[j - 1] = cur;
|
|
760
|
+
cur = next;
|
|
761
|
+
}
|
|
762
|
+
prev[b.length] = cur;
|
|
763
|
+
}
|
|
764
|
+
return prev[b.length];
|
|
765
|
+
}
|
|
766
|
+
|
|
715
767
|
function printPlaybookVerbHelp(verb) {
|
|
716
768
|
const cmds = {
|
|
717
769
|
plan: `plan — list playbooks + directives, grouped by scope.
|
|
@@ -798,10 +850,24 @@ Flags:
|
|
|
798
850
|
(code 2) when phases.detect.classification === 'detected'
|
|
799
851
|
OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
|
|
800
852
|
Logs PASS/FAIL reason to stderr.
|
|
801
|
-
--
|
|
853
|
+
--upstream-check (v0.11.14) Opt-in: query npm registry for the latest
|
|
854
|
+
published @blamejs/exceptd-skills version before
|
|
855
|
+
detect. Warns to stderr (no exit-code change) when
|
|
856
|
+
the local install is behind, so an operator using a
|
|
857
|
+
stale catalog finds out before the run completes.
|
|
858
|
+
--strict-preconditions Escalate warn-level precondition failures to halt.
|
|
859
|
+
Without this flag, only on_fail=halt preconditions
|
|
860
|
+
block; warn-level surface in stderr but the run
|
|
861
|
+
proceeds. With it, any precondition_check returning
|
|
862
|
+
false fails the run and exits non-zero.
|
|
863
|
+
--session-id <id> Reuse a specific session ID. Collisions refused
|
|
864
|
+
unless --force-overwrite is also passed.
|
|
865
|
+
--force-overwrite Override the session-id collision refusal.
|
|
802
866
|
--session-key <hex> HMAC sign the evidence_package with this key.
|
|
867
|
+
Output carries an 'hmac' field the verifier can check.
|
|
803
868
|
--force-stale Override the threat_currency_score < 50 hard-block.
|
|
804
|
-
--air-gap Honor air_gap_alternative paths.
|
|
869
|
+
--air-gap Honor air_gap_alternative paths in look.artifacts[]
|
|
870
|
+
and skip the network-touching collection variants.
|
|
805
871
|
--pretty Indented JSON output.
|
|
806
872
|
|
|
807
873
|
Attestation is persisted to .exceptd/attestations/<session_id>/ on every
|
|
@@ -835,12 +901,22 @@ newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
|
835
901
|
|
|
836
902
|
Subverbs:
|
|
837
903
|
attest show <sid> Emit the full (unredacted) attestation.
|
|
904
|
+
attest list Inventory every prior attestation under
|
|
905
|
+
~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
|
|
906
|
+
Filter with --playbook <id> or --since <ISO>. Newest
|
|
907
|
+
first; truncated evidence_hash + capture timestamp +
|
|
908
|
+
path per entry.
|
|
838
909
|
attest export <sid> Emit redacted JSON suitable for audit submission.
|
|
839
910
|
Strips raw artifact values; preserves evidence_hash,
|
|
840
911
|
signature, classification, RWEP, remediation choice.
|
|
841
|
-
--format csaf wraps the export in
|
|
912
|
+
--format <csaf|sarif|openvex> wraps the export in the
|
|
913
|
+
named envelope (default: redacted JSON).
|
|
842
914
|
attest verify <sid> Verify .sig sidecar against keys/public.pem.
|
|
843
915
|
Reports tamper status per attestation file.
|
|
916
|
+
attest diff <sid> Diff <sid> against the most-recent prior attestation
|
|
917
|
+
for the same playbook, or against --against <other-sid>
|
|
918
|
+
for an explicit pair. Reports unchanged | drifted |
|
|
919
|
+
resolved per evidence_hash + classification deltas.
|
|
844
920
|
|
|
845
921
|
All subverbs honor --pretty for indented JSON output.`,
|
|
846
922
|
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
@@ -867,7 +943,20 @@ Subchecks:
|
|
|
867
943
|
--currency Skill currency report (last_threat_review).
|
|
868
944
|
--cves CVE catalog validation (offline view).
|
|
869
945
|
--rfcs RFC catalog validation (offline view).
|
|
870
|
-
(
|
|
946
|
+
--registry-check (v0.11.14) Opt-in: query the npm registry for the
|
|
947
|
+
latest published version + days-since-publish.
|
|
948
|
+
Surfaces under checks.registry.{local_version,
|
|
949
|
+
published_version, same, behind, days_since_latest_publish}.
|
|
950
|
+
Off by default — keeps doctor offline-clean unless
|
|
951
|
+
asked.
|
|
952
|
+
--fix (v0.12.5) Attempt to auto-remediate detected gaps.
|
|
953
|
+
Currently scoped to: regenerate the local Ed25519
|
|
954
|
+
private key when keys/public.pem exists but
|
|
955
|
+
.keys/private.pem is absent. Does NOT modify any
|
|
956
|
+
file outside .keys/.
|
|
957
|
+
(no flag) All four subchecks above (sans --registry-check
|
|
958
|
+
unless explicitly requested), plus signing-status
|
|
959
|
+
(private key presence under .keys/).
|
|
871
960
|
|
|
872
961
|
Flags:
|
|
873
962
|
--json Emit JSON (default is human-readable text).
|
|
@@ -914,6 +1003,9 @@ exit-code contract designed for one-line .github/workflows entries.
|
|
|
914
1003
|
Flags:
|
|
915
1004
|
--all Run every playbook.
|
|
916
1005
|
--scope <type> Filter: system | code | service | cross-cutting.
|
|
1006
|
+
--required <ids> Comma-separated playbook ids that MUST run, even if
|
|
1007
|
+
scope-detection would exclude them. Fails if a
|
|
1008
|
+
required id is unknown.
|
|
917
1009
|
(no flag) Auto-detect scopes from cwd (same logic as run).
|
|
918
1010
|
--evidence <file> Submission bundle (multi-playbook shape).
|
|
919
1011
|
--evidence-dir <dir> Read <playbook-id>.json files from a directory.
|
|
@@ -921,11 +1013,77 @@ Flags:
|
|
|
921
1013
|
--block-on-jurisdiction-clock
|
|
922
1014
|
Fail when any close.notification_actions started a
|
|
923
1015
|
regulatory clock (GDPR 72h, HIPAA breach, etc.).
|
|
924
|
-
--
|
|
1016
|
+
--format <fmt> Output shape. Supported: json (default, single-line),
|
|
1017
|
+
summary (5-field digest), markdown (human digest).
|
|
1018
|
+
Bundles (csaf-2.0/sarif/openvex) live on per-run
|
|
1019
|
+
attestations, not the aggregate ci verdict.
|
|
1020
|
+
--json Force single-line JSON (overrides any TTY heuristics).
|
|
1021
|
+
--pretty Indented JSON output (implies --json).
|
|
1022
|
+
|
|
1023
|
+
Exit codes:
|
|
1024
|
+
0 PASS All scoped playbooks ran and verdict is clean.
|
|
1025
|
+
1 Framework error Runner threw, unreadable evidence, etc.
|
|
1026
|
+
2 FAIL (detected) At least one playbook returned
|
|
1027
|
+
classification=detected, OR rwep ≥ escalate, OR
|
|
1028
|
+
--max-rwep cap exceeded.
|
|
1029
|
+
3 Ran-but-no-evidence Every result was inconclusive AND no evidence was
|
|
1030
|
+
submitted (visibility gap — CI should fail loud).
|
|
1031
|
+
4 Blocked Result returned ok:false (preflight halt, missing
|
|
1032
|
+
preconditions with on_fail=halt, etc.) OR
|
|
1033
|
+
--block-on-jurisdiction-clock fired.
|
|
925
1034
|
|
|
926
|
-
Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
|
|
927
1035
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
928
|
-
max_rwep_observed, jurisdiction_clocks_started, verdict
|
|
1036
|
+
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
1037
|
+
results[].`,
|
|
1038
|
+
brief: `brief [playbook] — unified info doc (v0.11.0).
|
|
1039
|
+
|
|
1040
|
+
Collapses the three info-only phases plan + govern + direct + look into a
|
|
1041
|
+
single document. Phases 1-3 of the seven-phase contract are entirely
|
|
1042
|
+
informational; brief reads them in one CLI invocation instead of three.
|
|
1043
|
+
|
|
1044
|
+
Modes:
|
|
1045
|
+
brief Auto-detect playbooks for the cwd. Returns a list.
|
|
1046
|
+
brief <playbook> Single-playbook brief with jurisdiction obligations
|
|
1047
|
+
+ threat context + preconditions + artifacts +
|
|
1048
|
+
indicators.
|
|
1049
|
+
brief --all Every shipped playbook.
|
|
1050
|
+
brief --scope <type> Filter: system | code | service | cross-cutting.
|
|
1051
|
+
brief <pb> --phase <p> Emit only the named phase (govern | direct | look).
|
|
1052
|
+
Compat for legacy callers.
|
|
1053
|
+
|
|
1054
|
+
Flags:
|
|
1055
|
+
--directives Expand directive metadata per playbook.
|
|
1056
|
+
--pretty Indented JSON output.
|
|
1057
|
+
--json Force single-line JSON.
|
|
1058
|
+
|
|
1059
|
+
Output (single-playbook): playbook_id, directives[], jurisdiction_obligations[],
|
|
1060
|
+
threat_context, preconditions[], artifacts[], indicators[].`,
|
|
1061
|
+
lint: `lint <playbook> <evidence-file> — pre-flight check submission shape.
|
|
1062
|
+
|
|
1063
|
+
Validates the submission JSON against the playbook's expected indicators /
|
|
1064
|
+
preconditions / artifacts WITHOUT executing detect/analyze/validate/close.
|
|
1065
|
+
Lets the AI iterate on its evidence before going through phases 4-7.
|
|
1066
|
+
|
|
1067
|
+
Args / flags:
|
|
1068
|
+
<playbook> Playbook id. Required.
|
|
1069
|
+
<evidence-file> Submission JSON path. Required.
|
|
1070
|
+
--pretty Indented JSON output.
|
|
1071
|
+
|
|
1072
|
+
Output categories: ok, missing_required, missing_required_artifact,
|
|
1073
|
+
unknown_keys, type_mismatch, suggestions.`,
|
|
1074
|
+
"verify-attestation": `verify-attestation <session-id> — alias for \`attest verify\`.
|
|
1075
|
+
|
|
1076
|
+
See \`exceptd attest --help\` for the full attest verb. This alias matches
|
|
1077
|
+
the historical verify-attestation entry-point name used by some downstream
|
|
1078
|
+
consumers.
|
|
1079
|
+
|
|
1080
|
+
Flags: --pretty.`,
|
|
1081
|
+
"run-all": `run-all — alias for \`run --all\`.
|
|
1082
|
+
|
|
1083
|
+
Identical exit-code and output contract as \`run --all\`. Maintained for
|
|
1084
|
+
operators who script the verb form rather than the flag.
|
|
1085
|
+
|
|
1086
|
+
See \`exceptd run --help\` for the full flag list.`,
|
|
929
1087
|
};
|
|
930
1088
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
931
1089
|
}
|
|
@@ -1080,6 +1238,18 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
1080
1238
|
const playbookId = args._[0];
|
|
1081
1239
|
const onlyPhase = args.phase || null;
|
|
1082
1240
|
|
|
1241
|
+
// v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
|
|
1242
|
+
// Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
|
|
1243
|
+
// emitted the full brief — operators got no signal the flag was misused.
|
|
1244
|
+
// The legacy-compat surface is exactly the three v0.10.x verb names
|
|
1245
|
+
// (govern | direct | look); anything else is a typo or a misunderstanding.
|
|
1246
|
+
if (onlyPhase != null) {
|
|
1247
|
+
const ACCEPTED_PHASES = ["govern", "direct", "look"];
|
|
1248
|
+
if (!ACCEPTED_PHASES.includes(onlyPhase)) {
|
|
1249
|
+
return emitError(`brief: --phase "${onlyPhase}" not in accepted set ${JSON.stringify(ACCEPTED_PHASES)}.`, { verb: "brief", provided: onlyPhase }, pretty);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1083
1253
|
if (!playbookId || args.all) {
|
|
1084
1254
|
// Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
|
|
1085
1255
|
return cmdPlan(runner, args, runOpts, pretty);
|
|
@@ -1562,13 +1732,20 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1562
1732
|
|
|
1563
1733
|
emit(result, pretty);
|
|
1564
1734
|
|
|
1735
|
+
// v0.12.8: use process.exitCode + return instead of process.exit() so
|
|
1736
|
+
// buffered async stdout (which `emit` writes to) is allowed to drain
|
|
1737
|
+
// before the event loop ends. v0.11.10 (#100) is the canonical class:
|
|
1738
|
+
// process.exit(N) immediately after a stdout write can truncate output
|
|
1739
|
+
// under piped consumers (CI runners, jq, test harnesses).
|
|
1565
1740
|
if (classification === "detected") {
|
|
1566
1741
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
|
|
1567
|
-
process.
|
|
1742
|
+
process.exitCode = 2;
|
|
1743
|
+
return;
|
|
1568
1744
|
}
|
|
1569
1745
|
if (classification === "inconclusive" && escalate) {
|
|
1570
1746
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
|
|
1571
|
-
process.
|
|
1747
|
+
process.exitCode = 2;
|
|
1748
|
+
return;
|
|
1572
1749
|
}
|
|
1573
1750
|
if (classification === "inconclusive") {
|
|
1574
1751
|
process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
|
|
@@ -1667,10 +1844,19 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1667
1844
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
1668
1845
|
lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
|
|
1669
1846
|
const cves = obj.phases?.analyze?.matched_cves || [];
|
|
1847
|
+
const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
|
|
1670
1848
|
if (cves.length) {
|
|
1671
1849
|
lines.push(`\nMatched CVEs (${cves.length}):`);
|
|
1672
|
-
for (const c of cves.slice(0, 6))
|
|
1850
|
+
for (const c of cves.slice(0, 6)) {
|
|
1851
|
+
const via = Array.isArray(c.correlated_via) && c.correlated_via.length ? ` via ${c.correlated_via[0]}${c.correlated_via.length > 1 ? ` (+${c.correlated_via.length - 1})` : ""}` : "";
|
|
1852
|
+
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}${via}`);
|
|
1853
|
+
}
|
|
1673
1854
|
if (cves.length > 6) lines.push(` … ${cves.length - 6} more`);
|
|
1855
|
+
} else if (baseline.length) {
|
|
1856
|
+
// No evidence correlated to any CVE — clarify rather than implying the
|
|
1857
|
+
// operator is affected by the catalog enumeration. Pre-fix output read
|
|
1858
|
+
// like a hit list; explicit zero + scan-coverage callout fixes that.
|
|
1859
|
+
lines.push(`\nNo CVEs correlated to your evidence. Playbook catalog (informational): ${baseline.length} CVE(s) this playbook scans for.`);
|
|
1674
1860
|
}
|
|
1675
1861
|
const indicators = obj.phases?.detect?.indicators || [];
|
|
1676
1862
|
const hits = indicators.filter(i => i.verdict === "hit");
|
|
@@ -1693,7 +1879,16 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1693
1879
|
const issues = obj.preflight_issues || [];
|
|
1694
1880
|
if (issues.length) {
|
|
1695
1881
|
lines.push(`\nPreflight warnings (${issues.length}):`);
|
|
1696
|
-
|
|
1882
|
+
// v0.12.9 (P3 #12 from production smoke): handle preconditions without
|
|
1883
|
+
// an `on_fail` field (precondition.check was satisfied trivially or the
|
|
1884
|
+
// playbook omits the field). Pre-v0.12.9 these rendered as `[undefined]
|
|
1885
|
+
// <id>:`. Now: omit the bracket when on_fail is absent, and fall back
|
|
1886
|
+
// to the description if `check` is missing too.
|
|
1887
|
+
for (const i of issues) {
|
|
1888
|
+
const tag = i.on_fail ? `[${i.on_fail}] ` : "";
|
|
1889
|
+
const detail = i.check || i.description || i.reason || "(no detail)";
|
|
1890
|
+
lines.push(` ${tag}${i.id}: ${detail}`);
|
|
1891
|
+
}
|
|
1697
1892
|
}
|
|
1698
1893
|
lines.push(`\nFull structured result: --json (or --pretty for indented).`);
|
|
1699
1894
|
return lines.join("\n");
|
|
@@ -1798,9 +1993,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
1798
1993
|
// v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
|
|
1799
1994
|
// returned ok:false. Pre-0.11.9 the aggregate result had {ok:false} in
|
|
1800
1995
|
// the body but exit code stayed 0 — CI gates couldn't distinguish "ran
|
|
1801
|
-
// clean" from "blocked."
|
|
1996
|
+
// clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
|
|
1997
|
+
// the aggregate JSON emitted above is allowed to fully drain.
|
|
1802
1998
|
const anyBlocked = results.some(r => r.ok === false);
|
|
1803
|
-
if (anyBlocked) process.
|
|
1999
|
+
if (anyBlocked) { process.exitCode = 1; return; }
|
|
1804
2000
|
}
|
|
1805
2001
|
|
|
1806
2002
|
function cmdIngest(runner, args, runOpts, pretty) {
|
|
@@ -1835,28 +2031,38 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
1835
2031
|
|
|
1836
2032
|
const result = runner.run(playbookId, directiveId, cleanedSubmission, runOpts);
|
|
1837
2033
|
|
|
2034
|
+
// v0.12.8: route ingest's attestation persistence through persistAttestation
|
|
2035
|
+
// — the same path cmdRun + cmdRunMulti use — so the session-id collision
|
|
2036
|
+
// refusal AND the Ed25519 sidecar signing both apply. Pre-v0.12.8 ingest
|
|
2037
|
+
// had its own inline writeFileSync with neither check, meaning two ingest
|
|
2038
|
+
// calls with the same session-id silently clobbered the audit trail and no
|
|
2039
|
+
// .sig sidecar was written.
|
|
1838
2040
|
if (result && result.ok && result.session_id) {
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
);
|
|
1854
|
-
}
|
|
2041
|
+
const persisted = persistAttestation({
|
|
2042
|
+
sessionId: result.session_id,
|
|
2043
|
+
playbookId: result.playbook_id,
|
|
2044
|
+
directiveId: result.directive_id,
|
|
2045
|
+
evidenceHash: result.evidence_hash,
|
|
2046
|
+
operator: runOpts.operator,
|
|
2047
|
+
operatorConsent: runOpts.operator_consent,
|
|
2048
|
+
submission: cleanedSubmission,
|
|
2049
|
+
runOpts,
|
|
2050
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
2051
|
+
filename: "attestation.json",
|
|
2052
|
+
});
|
|
2053
|
+
if (!persisted.ok) {
|
|
2054
|
+
// Surface the collision; do not silently clobber.
|
|
2055
|
+
return emitError(persisted.error, { session_id: result.session_id, existing_path: persisted.existingPath }, pretty);
|
|
2056
|
+
}
|
|
2057
|
+
if (persisted.prior_session_id) {
|
|
2058
|
+
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
2059
|
+
}
|
|
1855
2060
|
}
|
|
1856
2061
|
|
|
1857
2062
|
if (result && result.ok === false) {
|
|
1858
2063
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
1859
|
-
process.
|
|
2064
|
+
process.exitCode = 1;
|
|
2065
|
+
return;
|
|
1860
2066
|
}
|
|
1861
2067
|
emit(result, pretty);
|
|
1862
2068
|
}
|
|
@@ -1981,6 +2187,15 @@ function persistAttestation(args) {
|
|
|
1981
2187
|
function maybeSignAttestation(filePath) {
|
|
1982
2188
|
const crypto = require("crypto");
|
|
1983
2189
|
const sigPath = filePath + ".sig";
|
|
2190
|
+
// v0.12.9 (P2 #3 from production smoke + codex P1 PR #4 review): keep the
|
|
2191
|
+
// sign key aligned with the VERIFY key. `attest verify` checks signatures
|
|
2192
|
+
// against PKG_ROOT/keys/public.pem; if we sign with cwd/.keys/private.pem
|
|
2193
|
+
// (e.g. the maintainer's repo-local keypair) the resulting `.sig` will
|
|
2194
|
+
// verify INVALID and report a false tamper signal on every freshly-written
|
|
2195
|
+
// attestation. PKG_ROOT-only resolution is the right answer; the original
|
|
2196
|
+
// smoke report's "doctor finds key, run does not" gap is fixed in `doctor`
|
|
2197
|
+
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
2198
|
+
// verifier doesn't trust.
|
|
1984
2199
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
1985
2200
|
const content = fs.readFileSync(filePath, "utf8");
|
|
1986
2201
|
// One-time-per-process unsigned warning so cron jobs don't spam stderr.
|
|
@@ -2714,6 +2929,46 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2714
2929
|
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2715
2930
|
};
|
|
2716
2931
|
if (!ok) issues.push("signatures");
|
|
2932
|
+
|
|
2933
|
+
// v0.12.9 (P3 #10 from production smoke): also run the shipped-tarball
|
|
2934
|
+
// round-trip gate (sign + pack + extract + verify) when the operator
|
|
2935
|
+
// opts in via --shipped-tarball. This is the v0.12.3 verify-as-shipped
|
|
2936
|
+
// gate that closed the v0.11.x → v0.12.4 signature regression class
|
|
2937
|
+
// (source-tree verify passed; shipped-tarball verify failed). It's
|
|
2938
|
+
// opt-in because npm pack adds ~5-10s and creates tempdir churn —
|
|
2939
|
+
// routine `doctor --signatures` stays fast.
|
|
2940
|
+
if (args["shipped-tarball"]) {
|
|
2941
|
+
try {
|
|
2942
|
+
const tarballScript = path.join(PKG_ROOT, "scripts", "verify-shipped-tarball.js");
|
|
2943
|
+
if (fs.existsSync(tarballScript)) {
|
|
2944
|
+
const tRes = spawnSync(process.execPath, [tarballScript], {
|
|
2945
|
+
encoding: "utf8",
|
|
2946
|
+
cwd: PKG_ROOT,
|
|
2947
|
+
timeout: 120000,
|
|
2948
|
+
});
|
|
2949
|
+
const tText = (tRes.stdout || "") + (tRes.stderr || "");
|
|
2950
|
+
const tOk = tRes.status === 0;
|
|
2951
|
+
const tMatch = tText.match(/(\d+)\/(\d+)\s+pass,\s+(\d+)\s+fail/i);
|
|
2952
|
+
checks.signatures.shipped_tarball = {
|
|
2953
|
+
ok: tOk,
|
|
2954
|
+
skills_passed: tMatch ? Number(tMatch[1]) : null,
|
|
2955
|
+
skills_total: tMatch ? Number(tMatch[2]) : null,
|
|
2956
|
+
skills_failed: tMatch ? Number(tMatch[3]) : null,
|
|
2957
|
+
...(tOk ? {} : { exit_code: tRes.status, raw: tText.slice(-500) }),
|
|
2958
|
+
};
|
|
2959
|
+
if (!tOk) issues.push("signatures.shipped_tarball");
|
|
2960
|
+
} else {
|
|
2961
|
+
checks.signatures.shipped_tarball = {
|
|
2962
|
+
ok: null,
|
|
2963
|
+
skipped: true,
|
|
2964
|
+
reason: "scripts/verify-shipped-tarball.js not present (likely an installed package, not a source checkout). The tarball-verify gate runs at release time; routine integrity is covered by `--signatures`.",
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
} catch (e) {
|
|
2968
|
+
checks.signatures.shipped_tarball = { ok: false, error: e.message };
|
|
2969
|
+
issues.push("signatures.shipped_tarball");
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2717
2972
|
} catch (e) {
|
|
2718
2973
|
checks.signatures = { ok: false, error: e.message };
|
|
2719
2974
|
issues.push("signatures");
|
|
@@ -2815,9 +3070,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2815
3070
|
|
|
2816
3071
|
if (runSigning) {
|
|
2817
3072
|
try {
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
3073
|
+
// v0.12.9 codex P1 (PR #4): report only PKG_ROOT — that's the path
|
|
3074
|
+
// maybeSignAttestation() and `attest verify` actually use. Pre-v0.12.9
|
|
3075
|
+
// doctor also reported cwd-resident keys as present, which gave a
|
|
3076
|
+
// false-positive "signing enabled" signal when the operator's cwd
|
|
3077
|
+
// key was misaligned with the PKG_ROOT-resident public key used at
|
|
3078
|
+
// verify time.
|
|
3079
|
+
const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3080
|
+
const present = fs.existsSync(keyPath);
|
|
2821
3081
|
// Bug #61 (v0.11.2): signing-status missing key is a real WARNING. The
|
|
2822
3082
|
// attestation pipeline writes unsigned files when this is absent, which
|
|
2823
3083
|
// operators reading the attestation later cannot verify for authenticity.
|
|
@@ -2902,10 +3162,9 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2902
3162
|
});
|
|
2903
3163
|
if (r.status === 0) {
|
|
2904
3164
|
// Re-verify the private key is now present so the JSON output reflects
|
|
2905
|
-
// the fix.
|
|
2906
|
-
const keyPath = path.join(
|
|
2907
|
-
const
|
|
2908
|
-
const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
|
|
3165
|
+
// the fix. v0.12.9 codex P1: PKG_ROOT-only (sign + verify use this path).
|
|
3166
|
+
const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3167
|
+
const present = fs.existsSync(keyPath);
|
|
2909
3168
|
checks.signing = { ok: present, severity: present ? "info" : "warn", private_key_present: present, can_sign_attestations: present };
|
|
2910
3169
|
out.checks = checks;
|
|
2911
3170
|
out.summary.fix_applied = "ed25519_keypair_generated";
|
|
@@ -2954,6 +3213,35 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2954
3213
|
? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
2955
3214
|
: `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
2956
3215
|
);
|
|
3216
|
+
// v0.12.9 (P3 #11 from production smoke): render registry-check in text mode.
|
|
3217
|
+
// Pre-v0.12.9 --registry-check populated checks.registry only in the JSON
|
|
3218
|
+
// output; operators in text mode had to add --json to see if the flag did
|
|
3219
|
+
// anything. Now the line surfaces in the human checklist.
|
|
3220
|
+
mark(checks.registry, c => {
|
|
3221
|
+
if (c.skipped) return `npm registry check: skipped (${c.reason || "unknown reason"})`;
|
|
3222
|
+
if (!c.ok && !c.same && c.behind) {
|
|
3223
|
+
const days = c.days_since_latest_publish != null ? `${c.days_since_latest_publish}d` : "?";
|
|
3224
|
+
return `npm registry: local v${c.local_version ?? "?"} BEHIND published v${c.published_version ?? "?"} (${days})`;
|
|
3225
|
+
}
|
|
3226
|
+
if (c.same) {
|
|
3227
|
+
return `npm registry: local v${c.local_version ?? "?"} == published v${c.published_version ?? "?"} (current)`;
|
|
3228
|
+
}
|
|
3229
|
+
if (c.ahead) {
|
|
3230
|
+
return `npm registry: local v${c.local_version ?? "?"} AHEAD of published v${c.published_version ?? "?"} (unreleased / dev install)`;
|
|
3231
|
+
}
|
|
3232
|
+
return `npm registry: check returned no comparison (raw exit=${c.exit_code ?? "?"})`;
|
|
3233
|
+
});
|
|
3234
|
+
// v0.12.9 (P3 #10): surface shipped_tarball sub-check when --shipped-tarball was used.
|
|
3235
|
+
if (checks.signatures?.shipped_tarball) {
|
|
3236
|
+
const st = checks.signatures.shipped_tarball;
|
|
3237
|
+
if (st.skipped) {
|
|
3238
|
+
lines.push(` [info] shipped tarball verify: skipped (${st.reason})`);
|
|
3239
|
+
} else if (st.ok) {
|
|
3240
|
+
lines.push(` [ok] shipped tarball verify: ${st.skills_passed ?? "?"}/${st.skills_total ?? "?"} skills pass on extracted tarball`);
|
|
3241
|
+
} else {
|
|
3242
|
+
lines.push(` [!!] shipped tarball verify FAILED: ${st.skills_failed ?? "?"}/${st.skills_total ?? "?"} skills fail (exit=${st.exit_code ?? "?"})`);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
2957
3245
|
if (checks.signing) {
|
|
2958
3246
|
if (checks.signing.private_key_present) {
|
|
2959
3247
|
lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
|
|
@@ -3196,13 +3484,22 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3196
3484
|
let handled = false;
|
|
3197
3485
|
let buf = "";
|
|
3198
3486
|
|
|
3487
|
+
// v0.12.8: every writeLine() in this handler writes to stdout. Replacing
|
|
3488
|
+
// process.exit() with exitCode + closing stdin lets the JSONL frames
|
|
3489
|
+
// drain before the event loop ends. `handled` plus process.stdin.pause()
|
|
3490
|
+
// prevents further callbacks from re-entering the handler.
|
|
3491
|
+
const finish = (code) => {
|
|
3492
|
+
process.exitCode = code;
|
|
3493
|
+
try { process.stdin.pause(); } catch { /* non-fatal */ }
|
|
3494
|
+
};
|
|
3199
3495
|
const handleLine = (line) => {
|
|
3200
3496
|
if (handled) return;
|
|
3201
3497
|
let parsed;
|
|
3202
3498
|
try { parsed = JSON.parse(line); }
|
|
3203
3499
|
catch (e) {
|
|
3500
|
+
handled = true;
|
|
3204
3501
|
writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
|
|
3205
|
-
|
|
3502
|
+
return finish(1);
|
|
3206
3503
|
}
|
|
3207
3504
|
if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
|
|
3208
3505
|
// Ignore non-evidence chatter so the host AI can interleave its own
|
|
@@ -3216,18 +3513,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3216
3513
|
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
3217
3514
|
} catch (e) {
|
|
3218
3515
|
writeLine({ event: "error", reason: `runner threw: ${e.message}` });
|
|
3219
|
-
|
|
3516
|
+
return finish(1);
|
|
3220
3517
|
}
|
|
3221
3518
|
if (!result || result.ok === false) {
|
|
3222
3519
|
writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
|
|
3223
|
-
|
|
3520
|
+
return finish(1);
|
|
3224
3521
|
}
|
|
3225
3522
|
writeLine({ phase: "detect", ...result.phases?.detect });
|
|
3226
3523
|
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
3227
3524
|
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
3228
3525
|
writeLine({ phase: "close", ...result.phases?.close });
|
|
3229
3526
|
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
3230
|
-
|
|
3527
|
+
return finish(0);
|
|
3231
3528
|
};
|
|
3232
3529
|
|
|
3233
3530
|
// Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
|
|
@@ -3235,7 +3532,8 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3235
3532
|
// a hung process.
|
|
3236
3533
|
if (process.stdin.isTTY) {
|
|
3237
3534
|
writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
|
|
3238
|
-
process.
|
|
3535
|
+
process.exitCode = 1;
|
|
3536
|
+
return;
|
|
3239
3537
|
}
|
|
3240
3538
|
|
|
3241
3539
|
process.stdin.on("data", (chunk) => {
|
|
@@ -3270,7 +3568,8 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3270
3568
|
} catch { /* fall through to error */ }
|
|
3271
3569
|
}
|
|
3272
3570
|
writeLine({ event: "error", reason: "stdin closed without an evidence event. Pipe `{\"event\":\"evidence\",\"payload\":{...}}` for streaming mode, or pass --no-stream + --evidence <file> for single-shot." });
|
|
3273
|
-
process.
|
|
3571
|
+
process.exitCode = 1;
|
|
3572
|
+
return;
|
|
3274
3573
|
}
|
|
3275
3574
|
});
|
|
3276
3575
|
|
|
@@ -3574,17 +3873,65 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3574
3873
|
const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
|
|
3575
3874
|
const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
|
|
3576
3875
|
|
|
3876
|
+
// v0.12.9 (P1 #2 from production smoke): reconcile verdict with exit code.
|
|
3877
|
+
// Pre-v0.12.9 the no-evidence-all-inconclusive path emitted verdict="PASS"
|
|
3878
|
+
// but the process exited 3 ("ran but no evidence"). CI consumers reading
|
|
3879
|
+
// exit code only failed a PASS run; consumers reading verdict only passed
|
|
3880
|
+
// a no-data run. Now compute the verdict up-front to match the exit-code
|
|
3881
|
+
// matrix (BLOCKED > FAIL > NO_EVIDENCE > PASS) so both surfaces agree.
|
|
3882
|
+
const suppliedEvidenceForVerdict = args.evidence || args["evidence-dir"];
|
|
3883
|
+
const blockedCount = results.filter(r => r && r.ok === false).length;
|
|
3884
|
+
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
3885
|
+
const totalForVerdict = results.length;
|
|
3886
|
+
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
3887
|
+
const computedVerdict = blockedCount > 0
|
|
3888
|
+
? "BLOCKED"
|
|
3889
|
+
: fail
|
|
3890
|
+
? "FAIL"
|
|
3891
|
+
: noEvidenceAllInconclusive
|
|
3892
|
+
? "NO_EVIDENCE"
|
|
3893
|
+
: "PASS";
|
|
3894
|
+
|
|
3895
|
+
// v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
|
|
3896
|
+
// mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
|
|
3897
|
+
// framework_gap_mapping per result; pre-v0.12.9 ci never aggregated them,
|
|
3898
|
+
// so operators got individual-playbook results only. Now: top-level
|
|
3899
|
+
// framework_gap_rollup lists each {framework, claimed_control} once with
|
|
3900
|
+
// the set of playbooks that flagged it — single-glance "what gaps did this
|
|
3901
|
+
// gate uncover across the scoped playbooks."
|
|
3902
|
+
const gapRollupMap = new Map();
|
|
3903
|
+
for (const r of results) {
|
|
3904
|
+
const gaps = r.phases?.analyze?.framework_gap_mapping || [];
|
|
3905
|
+
for (const g of gaps) {
|
|
3906
|
+
const key = `${g.framework || "unknown"}::${g.claimed_control || "unspecified"}`;
|
|
3907
|
+
const existing = gapRollupMap.get(key);
|
|
3908
|
+
if (existing) {
|
|
3909
|
+
if (!existing.playbooks.includes(r.playbook_id)) existing.playbooks.push(r.playbook_id);
|
|
3910
|
+
} else {
|
|
3911
|
+
gapRollupMap.set(key, {
|
|
3912
|
+
framework: g.framework || null,
|
|
3913
|
+
claimed_control: g.claimed_control || null,
|
|
3914
|
+
why_insufficient: g.why_insufficient || null,
|
|
3915
|
+
playbooks: [r.playbook_id],
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
const frameworkGapRollup = [...gapRollupMap.values()];
|
|
3921
|
+
|
|
3577
3922
|
const summary = {
|
|
3578
3923
|
total: results.length,
|
|
3579
3924
|
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
3580
|
-
inconclusive:
|
|
3925
|
+
inconclusive: inconclusiveCount,
|
|
3581
3926
|
not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
|
|
3582
|
-
blocked:
|
|
3927
|
+
blocked: blockedCount,
|
|
3583
3928
|
max_rwep_observed: maxRwepObserved,
|
|
3584
3929
|
jurisdiction_clocks_started: results
|
|
3585
3930
|
.flatMap(r => r.phases?.close?.notification_actions || [])
|
|
3586
3931
|
.filter(n => n && n.clock_started_at != null).length,
|
|
3587
|
-
|
|
3932
|
+
framework_gap_rollup: frameworkGapRollup,
|
|
3933
|
+
framework_gap_count: frameworkGapRollup.length,
|
|
3934
|
+
verdict: computedVerdict,
|
|
3588
3935
|
fail_reasons: failReasons,
|
|
3589
3936
|
};
|
|
3590
3937
|
|