@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,314 @@
1
+ // Portable, content-addressed evidence bundle and its OFFLINE verifier (X-702).
2
+ //
3
+ // A bundle is a directory:
4
+ //
5
+ // bundle.json the index: schema, manifest digest, every entry (role, logical name, digest, size), replay prerequisites
6
+ // blobs/<sha256 hex> one file per entry, named by the digest of its canonical bytes
7
+ //
8
+ // Everything a reviewer needs to check a release claim is in it (X-702.AC01): the assurance manifest, the SANITIZED findings, finding
9
+ // provenance, replay manifests, the dependency and toolchain identities, and the verification receipts the manifest cites. Nothing in it
10
+ // refers to the agent conversation that produced the work.
11
+ //
12
+ // The verifier (X-702.AC02) uses node:fs and node:crypto only. It never opens a socket and never executes anything from the bundle:
13
+ // it re-hashes every blob against the index, validates the manifest through manifest.js, checks that every receipt the manifest cites
14
+ // is present as a blob, and then DISCLOSES what a runtime replay would need (`replay.prerequisites`) without attempting one. A verified
15
+ // bundle therefore proves its contents are unmodified; it does not prove a replay would still reproduce today.
16
+ //
17
+ // Export and import enforce limits (X-702.AC03): entry count, per-blob and total bytes, logical-name shape (no absolute path, no `..`,
18
+ // no unusual characters), no symlinks, and a secret filter. The filter first REDACTS known secret shapes in findings and provenance, then
19
+ // refuses to write a blob in which any secret shape survives; the importer refuses a bundle that carries one. A missing blob, a modified
20
+ // blob or a modified index fails integrity validation; none is ever repaired or skipped silently.
21
+
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { canonicalJson } from '../evidence-bundle.js';
25
+ import { digestOf, digestOfBytes } from '../assurance/identity.js';
26
+ import { redactSecrets } from '../../llm-validator/redact.js';
27
+ import { validateManifest, manifestDigest } from './manifest.js';
28
+
29
+ export const BUNDLE_SCHEMA = 'agentic-security/portable-evidence-bundle';
30
+ const BUNDLE_VERSION = '1.0.0';
31
+ export const INDEX_FILE = 'bundle.json';
32
+ export const BLOB_DIR = 'blobs';
33
+ export const ROLES = Object.freeze(['manifest', 'findings', 'provenance', 'replay-manifest', 'toolchain', 'receipt']);
34
+ const REQUIRED_ROLES = Object.freeze(['manifest', 'findings', 'provenance', 'toolchain']);
35
+
36
+ export const BUNDLE_LIMITS = Object.freeze({ maxEntries: 4096, maxBlobBytes: 4 * 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024, maxIndexBytes: 8 * 1024 * 1024, maxNameLength: 200, maxDepth: 24, maxString: 8192 });
37
+
38
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
39
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
40
+
41
+ // ---------------------------------------------------------------- secret filter
42
+
43
+ const SECRET_PATTERNS = [
44
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/,
45
+ /\bAKIA[0-9A-Z]{16}\b/,
46
+ /\bgh[pousr]_[A-Za-z0-9]{30,}\b/,
47
+ /\bgithub_pat_[A-Za-z0-9_]{30,}\b/,
48
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/,
49
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
50
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/,
51
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
52
+ /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\b["']?\s*[:=]\s*["']?[^\s"',;]{8,}/i,
53
+ ];
54
+ const PLACEHOLDER = '[REDACTED-SECRET]';
55
+
56
+ /** The first secret shape found in a text, or null. */
57
+ export function findSecret(text) {
58
+ if (typeof text !== 'string') return null;
59
+ for (const re of SECRET_PATTERNS) { const m = re.exec(text); if (m && !m[0].includes(PLACEHOLDER)) return m[0].slice(0, 12) + '...'; }
60
+ return null;
61
+ }
62
+
63
+ /** Replace every known secret shape with a placeholder. */
64
+ function redactText(text) {
65
+ let out = String(text);
66
+ for (const re of SECRET_PATTERNS) out = out.replace(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'), PLACEHOLDER);
67
+ return redactSecrets(out).text;
68
+ }
69
+
70
+ function sanitizeValue(v, depth = 0) {
71
+ if (depth > BUNDLE_LIMITS.maxDepth) throw new Error('evidence is nested deeper than the limit');
72
+ if (typeof v === 'string') return redactText(v.length > BUNDLE_LIMITS.maxString ? v.slice(0, BUNDLE_LIMITS.maxString) : v);
73
+ if (Array.isArray(v)) return v.map((x) => sanitizeValue(x, depth + 1));
74
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sanitizeValue(v[k], depth + 1)]));
75
+ return v;
76
+ }
77
+
78
+ // A finding leaves the machine as a closed set of fields. Source snippets, taint traces and raw evidence stay behind.
79
+ const FINDING_FIELDS = ['id', 'stableId', 'severity', 'file', 'line', 'vuln', 'cwe', 'family', 'parser', 'confidence', 'confidenceTier', 'description', 'remediation', 'unreachable', 'findingProvenance'];
80
+
81
+ function sanitizeFindings(findings) {
82
+ return (Array.isArray(findings) ? findings : []).map((f) => {
83
+ const out = {};
84
+ for (const k of FINDING_FIELDS) if (f && f[k] !== undefined) out[k] = sanitizeValue(f[k]);
85
+ return out;
86
+ });
87
+ }
88
+
89
+ // ---------------------------------------------------------------- names and paths
90
+
91
+ export function checkLogicalName(name) {
92
+ if (typeof name !== 'string' || !name) return 'empty name';
93
+ if (name.length > BUNDLE_LIMITS.maxNameLength) return `name longer than ${BUNDLE_LIMITS.maxNameLength}`;
94
+ if (name.includes('\0') || name.includes('\\')) return 'name contains a NUL or backslash';
95
+ if (path.isAbsolute(name) || name.startsWith('/')) return 'absolute path';
96
+ if (name.split('/').some((s) => s === '..' || s === '.')) return 'path traversal';
97
+ if (!NAME_RE.test(name)) return 'name has characters outside the allowed set';
98
+ return null;
99
+ }
100
+
101
+ function blobFile(dir, digest) { return path.join(dir, BLOB_DIR, digest.slice('sha256:'.length)); }
102
+
103
+ function writeAtomic(file, bytes) {
104
+ const tmp = `${file}.tmp-${process.pid}`;
105
+ fs.writeFileSync(tmp, bytes, { flag: 'wx', mode: 0o644 });
106
+ fs.renameSync(tmp, file);
107
+ }
108
+
109
+ // ---------------------------------------------------------------- replay prerequisites
110
+
111
+ const BASE_PREREQUISITES = Object.freeze([
112
+ { code: 'source-at-commit', statement: 'the exact source revision named by the manifest subject must be checked out' },
113
+ { code: 'toolchain-identities', statement: 'the toolchain identities recorded in the bundle must be installed at the recorded versions' },
114
+ { code: 'confinement-backend', statement: 'a runtime replay needs a confinement backend; isolation-required replays are only advertised on Linux and enforcement there is unverified in this build' },
115
+ ]);
116
+
117
+ function replayPrerequisites(replayManifests) {
118
+ const seen = new Set(BASE_PREREQUISITES.map((p) => p.code));
119
+ const out = BASE_PREREQUISITES.map((p) => ({ ...p }));
120
+ for (const rm of replayManifests) {
121
+ for (const p of Array.isArray(rm?.prerequisites) ? rm.prerequisites : []) {
122
+ const code = typeof p === 'string' ? p : p?.code;
123
+ if (typeof code !== 'string' || seen.has(code)) continue;
124
+ seen.add(code);
125
+ out.push({ code, statement: typeof p === 'object' && typeof p.statement === 'string' ? p.statement : code, source: rm.id ?? null });
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // ---------------------------------------------------------------- export
132
+
133
+ /**
134
+ * Write a bundle to `outDir` (must not exist or must be empty). Throws on a limit, name, manifest or secret violation, and writes the
135
+ * index LAST so a half-written directory has no index and never verifies.
136
+ *
137
+ * @param {object} a
138
+ * @param {object} a.manifest a valid release assurance manifest
139
+ * @param {object[]} a.findings raw findings; sanitized here
140
+ * @param {object} a.provenance finding provenance and dependency identities
141
+ * @param {object[]} [a.replayManifests] `{ id, prerequisites?, ... }`
142
+ * @param {object} a.toolchain toolchain identities
143
+ * @param {Array<{id:string, content:*}>} [a.receipts] the verification receipt contents; each digest must equal the manifest's
144
+ */
145
+ export function exportBundle({ outDir, manifest, findings = [], provenance = {}, replayManifests = [], toolchain = {}, receipts = [] }) {
146
+ const v = validateManifest(manifest);
147
+ if (!v.ok) throw new Error(`refusing to export: the manifest is invalid (${v.errors[0].code} ${v.errors[0].path})`);
148
+ if (fs.existsSync(outDir) && fs.readdirSync(outDir).length > 0) throw new Error('refusing to export into a non-empty directory');
149
+
150
+ const items = [];
151
+ const add = (role, name, value, sanitize = true) => {
152
+ const bad = checkLogicalName(name);
153
+ if (bad) throw new Error(`refusing to export '${name}': ${bad}`);
154
+ const body = sanitize ? sanitizeValue(value) : value;
155
+ const bytes = Buffer.from(canonicalJson(body));
156
+ if (bytes.length > BUNDLE_LIMITS.maxBlobBytes) throw new Error(`refusing to export '${name}': ${bytes.length} bytes exceeds the ${BUNDLE_LIMITS.maxBlobBytes} byte limit`);
157
+ const hit = findSecret(bytes.toString('utf8'));
158
+ if (hit) throw new Error(`refusing to export '${name}': a secret shape survived redaction (${hit})`);
159
+ items.push({ role, name, digest: digestOfBytes(bytes), size: bytes.length, bytes });
160
+ };
161
+ add('manifest', 'manifest.json', manifest, false);
162
+ add('findings', 'findings.json', sanitizeFindings(findings), false);
163
+ add('provenance', 'provenance.json', provenance);
164
+ add('toolchain', 'toolchain.json', toolchain);
165
+ replayManifests.forEach((rm, i) => add('replay-manifest', `replay/${String(rm?.id ?? i).replace(/[^A-Za-z0-9._-]/g, '_')}.json`, rm));
166
+ const byId = new Map(receipts.map((r) => [r.id, r]));
167
+ for (const r of manifest.verificationReceipts) {
168
+ const given = byId.get(r.id);
169
+ if (!given) throw new Error(`refusing to export: receipt '${r.id}' cited by the manifest was not supplied`);
170
+ if (digestOf(given.content) !== r.digest) throw new Error(`refusing to export: receipt '${r.id}' content does not match the digest the manifest binds`);
171
+ add('receipt', `receipts/${r.id.replace(/[^A-Za-z0-9._-]/g, '_')}.json`, given.content);
172
+ }
173
+ if (items.length > BUNDLE_LIMITS.maxEntries) throw new Error('refusing to export: too many entries');
174
+ const total = items.reduce((n, i) => n + i.size, 0);
175
+ if (total > BUNDLE_LIMITS.maxTotalBytes) throw new Error('refusing to export: total size exceeds the limit');
176
+ const names = new Set();
177
+ for (const i of items) { if (names.has(i.name)) throw new Error(`refusing to export: duplicate name '${i.name}'`); names.add(i.name); }
178
+
179
+ fs.mkdirSync(path.join(outDir, BLOB_DIR), { recursive: true });
180
+ for (const i of items) {
181
+ const f = blobFile(outDir, i.digest);
182
+ if (!fs.existsSync(f)) writeAtomic(f, i.bytes);
183
+ }
184
+ const entries = items.map(({ role, name, digest, size }) => ({ role, name, digest, size })).sort((a, b) => (a.name < b.name ? -1 : 1));
185
+ const index = indexFor(entries, manifest, replayManifests);
186
+ writeAtomic(path.join(outDir, INDEX_FILE), Buffer.from(JSON.stringify(index, null, 2)));
187
+ return { bundleDigest: index.bundleDigest, manifestDigest: index.manifestDigest, entries: entries.length, totalBytes: total };
188
+ }
189
+
190
+ function indexFor(entries, manifest, replayManifests) {
191
+ const body = {
192
+ schema: BUNDLE_SCHEMA, schemaVersion: BUNDLE_VERSION,
193
+ ...(manifest.synthetic === true ? { synthetic: true } : {}),
194
+ manifestId: manifest.id, manifestDigest: manifestDigest(manifest), entries,
195
+ replay: { attempted: false, prerequisites: replayPrerequisites(replayManifests) },
196
+ limits: BUNDLE_LIMITS,
197
+ };
198
+ return { ...body, bundleDigest: bundleDigestOf(body) };
199
+ }
200
+
201
+ /** The identity of a bundle: the manifest digest plus every entry's digest, independent of file order. */
202
+ export function bundleDigestOf(index) {
203
+ return digestOf({ manifestDigest: index.manifestDigest, entries: index.entries.map((e) => [e.role, e.name, e.digest, e.size]) });
204
+ }
205
+
206
+ // ---------------------------------------------------------------- verify (offline)
207
+
208
+ function readBounded(file, max) {
209
+ const st = fs.lstatSync(file);
210
+ if (st.isSymbolicLink() || !st.isFile()) throw Object.assign(new Error('not a regular file'), { code: 'NOT_REGULAR' });
211
+ if (st.size > max) throw Object.assign(new Error(`${st.size} bytes exceeds ${max}`), { code: 'TOO_LARGE' });
212
+ const fd = fs.openSync(file, 'r');
213
+ try {
214
+ const buf = Buffer.alloc(Math.min(st.size, max) + 1);
215
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
216
+ if (n > max) throw Object.assign(new Error('grew past the limit while reading'), { code: 'TOO_LARGE' });
217
+ return buf.subarray(0, n);
218
+ } finally { fs.closeSync(fd); }
219
+ }
220
+
221
+ /**
222
+ * Verify a bundle directory using only the local filesystem. Returns `{ ok, errors, manifest, entries, replay, network:false }`.
223
+ * `errors` carry a stable `code`: BAD_INDEX, BAD_ENTRY, MISSING_BLOB, DIGEST_MISMATCH, SIZE_MISMATCH, TOO_LARGE, NOT_REGULAR,
224
+ * BUNDLE_DIGEST_MISMATCH, MANIFEST_INVALID, MANIFEST_MISMATCH, MISSING_EVIDENCE, MISSING_ROLE, SECRET_IN_BLOB, LIMIT.
225
+ */
226
+ export function verifyBundle(dir) {
227
+ const errors = [];
228
+ const err = (code, message, extra = {}) => errors.push({ code, message, ...extra });
229
+ const out = (extra = {}) => ({ ok: errors.length === 0, errors, network: false, manifest: null, entries: [], replay: { attempted: false, prerequisites: [], statement: 'replay was not attempted: this verifier is offline and executes nothing from the bundle' }, ...extra });
230
+
231
+ let index;
232
+ try { index = JSON.parse(readBounded(path.join(dir, INDEX_FILE), BUNDLE_LIMITS.maxIndexBytes).toString('utf8')); } catch (e) {
233
+ err('BAD_INDEX', `cannot read ${INDEX_FILE}: ${e.code || e.message}`); return out();
234
+ }
235
+ if (!index || index.schema !== BUNDLE_SCHEMA || !Array.isArray(index.entries) || typeof index.bundleDigest !== 'string') { err('BAD_INDEX', 'not a portable evidence bundle index'); return out(); }
236
+ if (index.entries.length > BUNDLE_LIMITS.maxEntries) { err('LIMIT', 'too many entries'); return out(); }
237
+
238
+ const names = new Set();
239
+ let total = 0;
240
+ const loaded = new Map();
241
+ for (const e of index.entries) {
242
+ if (!e || !ROLES.includes(e.role) || !DIGEST_RE.test(e.digest ?? '') || !Number.isInteger(e.size) || e.size < 0) { err('BAD_ENTRY', 'malformed entry', { entry: e?.name ?? null }); continue; }
243
+ const bad = checkLogicalName(e.name);
244
+ if (bad) { err('BAD_ENTRY', `entry name rejected: ${bad}`, { entry: String(e.name).slice(0, 60) }); continue; }
245
+ if (names.has(e.name)) { err('BAD_ENTRY', 'duplicate entry name', { entry: e.name }); continue; }
246
+ names.add(e.name);
247
+ if (e.size > BUNDLE_LIMITS.maxBlobBytes) { err('TOO_LARGE', `entry exceeds the per-blob limit`, { entry: e.name }); continue; }
248
+ total += e.size;
249
+ let bytes;
250
+ try { bytes = readBounded(blobFile(dir, e.digest), BUNDLE_LIMITS.maxBlobBytes); } catch (x) {
251
+ err(x.code === 'ENOENT' ? 'MISSING_BLOB' : (x.code || 'MISSING_BLOB'), `blob for '${e.name}' is unavailable: ${x.code || x.message}`, { entry: e.name }); continue;
252
+ }
253
+ if (bytes.length !== e.size) { err('SIZE_MISMATCH', `blob size differs from the index`, { entry: e.name }); continue; }
254
+ if (digestOfBytes(bytes) !== e.digest) { err('DIGEST_MISMATCH', `blob for '${e.name}' was modified after export`, { entry: e.name }); continue; }
255
+ const hit = findSecret(bytes.toString('utf8'));
256
+ if (hit) err('SECRET_IN_BLOB', `'${e.name}' carries a secret shape`, { entry: e.name });
257
+ loaded.set(e.name, { e, bytes });
258
+ }
259
+ if (total > BUNDLE_LIMITS.maxTotalBytes) err('LIMIT', 'total size exceeds the limit');
260
+ for (const role of REQUIRED_ROLES) if (!index.entries.some((e) => e?.role === role)) err('MISSING_ROLE', `required role '${role}' is absent`);
261
+ if (index.entries.every((e) => e && DIGEST_RE.test(e.digest ?? '')) && bundleDigestOf(index) !== index.bundleDigest) err('BUNDLE_DIGEST_MISMATCH', 'the index does not match its recorded bundle digest');
262
+
263
+ // manifest
264
+ let manifest = null;
265
+ const mEntry = index.entries.find((e) => e?.role === 'manifest');
266
+ if (mEntry && loaded.has(mEntry.name)) {
267
+ try { manifest = JSON.parse(loaded.get(mEntry.name).bytes.toString('utf8')); } catch { err('MANIFEST_INVALID', 'manifest is not JSON'); }
268
+ if (manifest) {
269
+ const v = validateManifest(manifest);
270
+ if (!v.ok) err('MANIFEST_INVALID', `manifest fails validation: ${v.errors[0].code} ${v.errors[0].path}`, { detail: v.errors.slice(0, 5) });
271
+ else {
272
+ if (manifestDigest(manifest) !== index.manifestDigest) err('MANIFEST_MISMATCH', 'the manifest is not the one the index was built for');
273
+ const have = new Set(index.entries.filter((e) => e?.role === 'receipt').map((e) => e.digest));
274
+ for (const r of manifest.verificationReceipts) if (!have.has(r.digest)) err('MISSING_EVIDENCE', `receipt '${r.id}' cited by the manifest is not in the bundle`, { receipt: r.id });
275
+ }
276
+ }
277
+ }
278
+ const replayManifests = [];
279
+ for (const [, { e, bytes }] of loaded) if (e.role === 'replay-manifest') { try { replayManifests.push(JSON.parse(bytes.toString('utf8'))); } catch { /* integrity already checked; unparsable replay manifest adds no prerequisites */ } }
280
+ return out({
281
+ manifest: errors.length === 0 ? manifest : null,
282
+ entries: [...loaded.values()].map(({ e }) => ({ role: e.role, name: e.name, digest: e.digest })),
283
+ bundleDigest: index.bundleDigest,
284
+ replay: { attempted: false, prerequisites: replayPrerequisites(replayManifests), statement: 'replay was not attempted: this verifier is offline and executes nothing from the bundle' },
285
+ });
286
+ }
287
+
288
+ // ---------------------------------------------------------------- import
289
+
290
+ /**
291
+ * Verify `from`, then materialize its entries under `to` using their logical names. Verification failure writes nothing. Every
292
+ * destination is resolved and confined to `to`; an existing destination is never overwritten.
293
+ */
294
+ export function importBundle({ from, to }) {
295
+ const v = verifyBundle(from);
296
+ if (!v.ok) return { ok: false, errors: v.errors, written: [] };
297
+ const index = JSON.parse(fs.readFileSync(path.join(from, INDEX_FILE), 'utf8'));
298
+ const root = path.resolve(to);
299
+ const plan = [];
300
+ for (const e of index.entries) {
301
+ const dest = path.resolve(root, e.name);
302
+ if (dest !== root && !dest.startsWith(root + path.sep)) return { ok: false, errors: [{ code: 'BAD_ENTRY', message: `entry '${e.name}' escapes the destination` }], written: [] };
303
+ if (fs.existsSync(dest)) return { ok: false, errors: [{ code: 'DESTINATION_EXISTS', message: `'${e.name}' already exists at the destination` }], written: [] };
304
+ plan.push({ e, dest });
305
+ }
306
+ fs.mkdirSync(root, { recursive: true });
307
+ const written = [];
308
+ for (const { e, dest } of plan) {
309
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
310
+ writeAtomic(dest, readBounded(blobFile(from, e.digest), BUNDLE_LIMITS.maxBlobBytes));
311
+ written.push(e.name);
312
+ }
313
+ return { ok: true, errors: [], written, bundleDigest: v.bundleDigest, replay: v.replay };
314
+ }
@@ -0,0 +1,142 @@
1
+ // `agentic-security portfolio <progress|retention|backend|export|import>` (X-707, X-708 CLI surface).
2
+ //
3
+ // portfolio progress --store <file> [--budgets <file>] [--findings <file>] [--blocking-severity <s>] [--now <ms>] [--json]
4
+ // The coverage-aware progress view (see portfolio/progress.js). Exit 0, 1 refused (feature off, store failed verification), 2 usage.
5
+ // portfolio retention plan|apply --records <file> --root <dir> [--policy <file>] [--holds <file>] [--store <file>]
6
+ // [--log <file> --actor <name>] [--now <ms>] [--json]
7
+ // Retention by class with legal holds (see portfolio/retention.js). `plan` deletes nothing. `apply` needs --log and --actor, logs
8
+ // each deletion first, and exits 1 if anything it should have deleted could not be deleted. Exit 0 ok, 1 refused or incomplete, 2 usage.
9
+ // portfolio retention verify-log --log <file> checks the hash chain of the deletion log. Exit 0 intact, 1 broken.
10
+ // portfolio backend probe --mode local|shared --dir <dir>
11
+ // Reports whether a backend is usable. An unusable shared backend is the typed `blocked` state, exit 1; nothing is created.
12
+ // portfolio export --store <file> --out <file> [--retention-log <file>] one self-checking, secret-free file for an air-gapped machine
13
+ // portfolio import --from <file> --mode local|shared --dir <dir> verifies first; never overwrites a store
14
+ //
15
+ // Every subcommand is off unless the `portfolio-assurance` feature is on (assurance/config.js), so a default install behaves exactly as
16
+ // before. Nothing here makes a network call: the whole command works air-gapped. The logic lives here, not in bin/, so tests run it
17
+ // without spawning the CLI; bin/ only dispatches. Paths are the operator's own and resolve against the working directory (the MCP
18
+ // tool, which takes paths from an agent, confines them to its session root instead).
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { resolveAssuranceConfig, featureStatus } from '../assurance/config.js';
22
+ import { FEATURE_ID } from './wording.js';
23
+ import { readStore } from './work-units.js';
24
+ import { readLedger } from './scheduler.js';
25
+ import { buildProgressView } from './progress.js';
26
+ import { applyRetention, planRetention, verifyRetentionLog } from './retention.js';
27
+ import { openBackend, exportState, importState, verifyStateExport } from './backend.js';
28
+
29
+ const USAGE = [
30
+ 'Usage: agentic-security portfolio progress --store <file> [--budgets <file>] [--findings <file>] [--blocking-severity <s>] [--now <ms>] [--json]',
31
+ ' agentic-security portfolio retention plan|apply --records <file> --root <dir> [--policy <file>] [--holds <file>] [--store <file>] [--log <file> --actor <name>] [--now <ms>] [--json]',
32
+ ' agentic-security portfolio retention verify-log --log <file>',
33
+ ' agentic-security portfolio backend probe --mode local|shared --dir <dir>',
34
+ ' agentic-security portfolio export --store <file> --out <file> [--retention-log <file>]',
35
+ ' agentic-security portfolio import --from <file> --mode local|shared --dir <dir>',
36
+ ].join('\n');
37
+
38
+ const abs = (cwd, p) => path.resolve(cwd, String(p));
39
+ const readJsonFile = (file) => {
40
+ const st = fs.lstatSync(file);
41
+ if (st.isSymbolicLink() || !st.isFile() || st.size > 32 * 1024 * 1024) throw new Error('not a regular JSON file within the size limit');
42
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
43
+ };
44
+
45
+ /**
46
+ * @param {{ _: string[], flags: object }} args
47
+ * @param {{ cwd?: string, out?: (s: string) => void, err?: (s: string) => void, env?: object, now?: () => number }} [io]
48
+ * @returns {Promise<number>} the exit code
49
+ */
50
+ export async function runPortfolioCommand(args, io = {}) {
51
+ const cwd = io.cwd || process.cwd();
52
+ const out = io.out || ((s) => process.stdout.write(s));
53
+ const err = io.err || ((s) => process.stderr.write(s));
54
+ const sub = args._[1];
55
+ const flags = args.flags || {};
56
+ const clock = io.now || (() => Date.now());
57
+ const emit = (value, lines) => out(flags.json ? `${JSON.stringify(value, null, 2)}\n` : `${lines.join('\n')}\n`);
58
+ if (!['progress', 'retention', 'backend', 'export', 'import'].includes(sub)) { err(`${USAGE}\n`); return 2; }
59
+
60
+ const config = resolveAssuranceConfig({ scanRoot: cwd, env: io.env || process.env });
61
+ const gate = featureStatus(config, FEATURE_ID);
62
+ if (gate.status !== 'ok') { err(`agentic-security portfolio ${sub}: ${gate.status}: ${gate.reason ?? gate.code}\n`); return 1; }
63
+ const nowMs = flags.now !== undefined ? Number(flags.now) : clock();
64
+
65
+ try {
66
+ if (sub === 'progress') {
67
+ if (typeof flags.store !== 'string') { err(`${USAGE}\n`); return 2; }
68
+ const storePath = abs(cwd, flags.store);
69
+ const store = readStore(storePath);
70
+ if (!store) { err('agentic-security portfolio progress: no portfolio store at that path\n'); return 1; }
71
+ const r = buildProgressView({
72
+ store, ledger: readLedger(storePath), now: nowMs,
73
+ budgets: typeof flags.budgets === 'string' ? readJsonFile(abs(cwd, flags.budgets)) : null,
74
+ findings: typeof flags.findings === 'string' ? readJsonFile(abs(cwd, flags.findings)) : null,
75
+ blockingSeverity: typeof flags['blocking-severity'] === 'string' ? flags['blocking-severity'] : undefined,
76
+ });
77
+ if (!r.ok) { err(`agentic-security portfolio progress: ${r.errors[0].message}\n`); return 1; }
78
+ emit(r.view, r.view.lines);
79
+ return 0;
80
+ }
81
+
82
+ if (sub === 'retention') {
83
+ const mode = args._[2];
84
+ if (mode === 'verify-log') {
85
+ if (typeof flags.log !== 'string') { err(`${USAGE}\n`); return 2; }
86
+ const v = verifyRetentionLog(abs(cwd, flags.log));
87
+ emit(v, [v.ok ? `Deletion log verified: ${v.entries.length} entr${v.entries.length === 1 ? 'y' : 'ies'}, chain intact.` : `Deletion log FAILED verification: ${v.errors[0].message}`]);
88
+ return v.ok ? 0 : 1;
89
+ }
90
+ if (!['plan', 'apply'].includes(mode) || typeof flags.records !== 'string' || typeof flags.root !== 'string') { err(`${USAGE}\n`); return 2; }
91
+ const input = {
92
+ records: readJsonFile(abs(cwd, flags.records)), now: nowMs,
93
+ policy: typeof flags.policy === 'string' ? readJsonFile(abs(cwd, flags.policy)) : undefined,
94
+ holds: typeof flags.holds === 'string' ? readJsonFile(abs(cwd, flags.holds)) : [],
95
+ store: typeof flags.store === 'string' ? readStore(abs(cwd, flags.store)) : null,
96
+ };
97
+ if (mode === 'plan') {
98
+ const plan = planRetention(input);
99
+ if (!plan.ok) { err(`agentic-security portfolio retention: ${plan.errors[0].message}\n`); return 1; }
100
+ emit(plan, [`Retention plan (policy ${plan.policyVersion}): ${plan.summary.delete} to delete, ${plan.summary.keep} kept (${plan.summary.heldByLegalHold} under legal hold), ${plan.summary.protect} protected as required current receipts${plan.summary.expiredButRequired ? ` (${plan.summary.expiredButRequired} past retention but required: deletion blocked)` : ''}.`, 'Nothing was deleted.']);
101
+ return 0;
102
+ }
103
+ if (typeof flags.log !== 'string' || typeof flags.actor !== 'string') { err(`${USAGE}\n`); return 2; }
104
+ const r = applyRetention({ ...input, root: abs(cwd, flags.root), logFile: abs(cwd, flags.log), actor: flags.actor });
105
+ if (!r.ok) { err(`agentic-security portfolio retention: ${r.errors[0].message}\n`); return 1; }
106
+ emit(r, [`Retention applied: ${r.deleted.length} deleted and logged, ${r.blocked.length} blocked as required current receipts, ${r.failed.length} could not be deleted.`, ...r.failed.map((f) => ` ${f.id}: ${f.code}: ${f.message}`)]);
107
+ return r.failed.length ? 1 : 0;
108
+ }
109
+
110
+ if (sub === 'backend') {
111
+ if (args._[2] !== 'probe' || typeof flags.dir !== 'string') { err(`${USAGE}\n`); return 2; }
112
+ const b = openBackend({ mode: flags.mode, dir: abs(cwd, flags.dir) });
113
+ if (!b.ok) { emit(b, [`Backend ${flags.mode ?? '?'}: ${b.state} (${b.code}): ${b.reason}`, 'Nothing was created and no other location is used instead.']); return 1; }
114
+ const d = b.backend.describe();
115
+ emit({ ok: true, ...d }, [`Backend ${d.kind} at ${d.root}: usable.`, ` Network filesystems: ${d.consistency.networkFilesystem}.`]);
116
+ return 0;
117
+ }
118
+
119
+ if (sub === 'export') {
120
+ if (typeof flags.store !== 'string' || typeof flags.out !== 'string') { err(`${USAGE}\n`); return 2; }
121
+ const r = exportState({ storeFile: abs(cwd, flags.store), outFile: abs(cwd, flags.out), retentionLog: typeof flags['retention-log'] === 'string' ? abs(cwd, flags['retention-log']) : null, now: nowMs });
122
+ emit(r, [`Exported ${r.units} unit(s) to ${flags.out} (${r.digest}).`]);
123
+ return 0;
124
+ }
125
+
126
+ if (sub === 'import') {
127
+ if (typeof flags.from !== 'string' || typeof flags.dir !== 'string') { err(`${USAGE}\n`); return 2; }
128
+ const b = openBackend({ mode: flags.mode, dir: abs(cwd, flags.dir) });
129
+ if (!b.ok) { err(`agentic-security portfolio import: ${b.state} (${b.code}): ${b.reason}\n`); return 1; }
130
+ const v = verifyStateExport(abs(cwd, flags.from));
131
+ if (!v.ok) { err(`agentic-security portfolio import: ${v.code}: ${v.reason}\n`); return 1; }
132
+ const r = importState({ from: abs(cwd, flags.from), backend: b.backend });
133
+ if (!r.ok) { err(`agentic-security portfolio import: ${r.code}: ${r.reason}\n`); return 1; }
134
+ emit(r, [`Imported ${r.units} unit(s) into ${r.file}.`]);
135
+ return 0;
136
+ }
137
+ } catch (e) {
138
+ err(`agentic-security portfolio ${sub}: ${e.code ?? 'error'}: ${String(e.message).slice(0, 300)}\n`);
139
+ return 1;
140
+ }
141
+ return 2;
142
+ }