@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.1

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 (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +6 -0
  3. package/dist/1336.index.js +891 -719
  4. package/dist/550.index.js +548 -0
  5. package/dist/7405.index.js +885 -0
  6. package/dist/9382.index.js +4386 -0
  7. package/dist/9579.index.js +1197 -0
  8. package/dist/agentic-security.mjs +4 -4
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +4 -4
  11. package/src/context-intelligence/CLAUDE.md +25 -0
  12. package/src/context-intelligence/adapters/existing-ir.js +14 -0
  13. package/src/context-intelligence/adapters/haskell-hie.js +165 -0
  14. package/src/context-intelligence/build.js +68 -0
  15. package/src/context-intelligence/candidates.js +115 -0
  16. package/src/context-intelligence/canonical.js +120 -0
  17. package/src/context-intelligence/capabilities.js +33 -0
  18. package/src/context-intelligence/cli.js +426 -0
  19. package/src/context-intelligence/config.js +91 -0
  20. package/src/context-intelligence/controls.js +244 -0
  21. package/src/context-intelligence/diff.js +97 -0
  22. package/src/context-intelligence/entrypoints.js +336 -0
  23. package/src/context-intelligence/evaluation/index.js +235 -0
  24. package/src/context-intelligence/evaluation/runner.js +139 -0
  25. package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
  26. package/src/context-intelligence/files.js +80 -0
  27. package/src/context-intelligence/graph-schema.js +227 -0
  28. package/src/context-intelligence/imports.js +123 -0
  29. package/src/context-intelligence/manifest.js +99 -0
  30. package/src/context-intelligence/packer.js +224 -0
  31. package/src/context-intelligence/persist.js +40 -0
  32. package/src/context-intelligence/prompts/packs.js +82 -0
  33. package/src/context-intelligence/queue.js +355 -0
  34. package/src/context-intelligence/reachability.js +188 -0
  35. package/src/context-intelligence/repo-info.js +16 -0
  36. package/src/context-intelligence/review.js +370 -0
  37. package/src/context-intelligence/spans.js +147 -0
  38. package/src/context-intelligence/store.js +203 -0
  39. package/src/posture/artifact-registry.js +2 -0
@@ -0,0 +1,203 @@
1
+ //
2
+ // Governed persistence for DCI artifacts, under `<project>/.agentic-security/context-intelligence/`.
3
+ //
4
+ // Layout (all content-addressed where it can be):
5
+ // bundles/<contextHash>/manifest.json, context-<n>.txt one directory per bundle set
6
+ // index.json entry point -> current bundle, plus the build identity
7
+ // reviews/<jobKey>.json one verified review result per job
8
+ // queues/<runId>.json durable job-queue state (resume / cancel)
9
+ // batches/<batchId>/ offline batch request and result files
10
+ //
11
+ // Every write is a temp file + rename, opened O_EXCL | O_NOFOLLOW, so a symlink or hard link planted at the target is
12
+ // REPLACED, never written through. Directories are created 0700 and files 0600. The state root must resolve inside
13
+ // the project root. Reads re-verify: a stored text file whose hash no longer matches its manifest is an ERROR.
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as crypto from 'node:crypto';
17
+ import { statePath, STATE_DIR_NAME, stateWritesEnabled, isSafeStateDir } from '../posture/state-dir.js';
18
+ import { loadLegalHolds, isUnderHold } from '../posture/legal-hold.js';
19
+ import { validateManifest } from './manifest.js';
20
+ import { sha256 } from './canonical.js';
21
+
22
+ export const STORE_DIR = 'context-intelligence';
23
+ export const ARTIFACT_NAME = STORE_DIR;
24
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
25
+ const MAX_JSON_BYTES = 32 * 1024 * 1024;
26
+
27
+ export function storeRoot(root) { return statePath(root, STORE_DIR); }
28
+
29
+ function refuse(code, reason) { return { ok: false, code, reason }; }
30
+
31
+ function segments(rel) {
32
+ const segs = String(rel).split('/');
33
+ if (!segs.length || segs.some(s => !SAFE_SEGMENT.test(s) || s === '.' || s === '..')) return null;
34
+ return segs;
35
+ }
36
+
37
+ /** Create the directory chain below the store root, refusing links and anything resolving outside the project. */
38
+ function ensureDirs(root, segs) {
39
+ const base = storeRoot(root);
40
+ const projectReal = fs.realpathSync(path.resolve(root));
41
+ const stateRoot = path.join(projectReal, STATE_DIR_NAME);
42
+ if (!isSafeStateDir(stateRoot)) return refuse('unsafe-state-dir', 'no project marker beside the state directory');
43
+ fs.mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
44
+ let cur = path.join(stateRoot, STORE_DIR);
45
+ void base;
46
+ for (const seg of [null, ...segs]) {
47
+ if (seg !== null) cur = path.join(cur, seg);
48
+ try {
49
+ const st = fs.lstatSync(cur);
50
+ if (st.isSymbolicLink()) return refuse('symlink', `${path.relative(projectReal, cur)} is a symbolic link`);
51
+ if (!st.isDirectory()) return refuse('not-a-directory', path.relative(projectReal, cur));
52
+ } catch (e) {
53
+ if (e && e.code !== 'ENOENT') return refuse('stat-failed', String(e.code || e));
54
+ fs.mkdirSync(cur, { mode: 0o700 });
55
+ }
56
+ }
57
+ const real = fs.realpathSync(cur);
58
+ if (real !== stateRoot && !real.startsWith(stateRoot + path.sep)) return refuse('escapes-state-root', 'the store resolves outside the project');
59
+ return { ok: true, dir: real };
60
+ }
61
+
62
+ /** Atomic, link-safe write of one file below the store root. `rel` is like `bundles/<hash>/manifest.json`. */
63
+ export function writeStoreFile(root, rel, body) {
64
+ try {
65
+ if (!stateWritesEnabled()) return refuse('state-writes-disabled', 'state writes are switched off for this process');
66
+ if (typeof body !== 'string' && !Buffer.isBuffer(body)) return refuse('bad-body', 'body must be a string or Buffer');
67
+ if (Buffer.byteLength(body) > MAX_JSON_BYTES) return refuse('too-large', 'artifact exceeds the size ceiling');
68
+ const segs = segments(rel);
69
+ if (!segs) return refuse('bad-path', 'store paths are safe relative names');
70
+ const name = segs.pop();
71
+ const d = ensureDirs(root, segs);
72
+ if (!d.ok) return d;
73
+ const target = path.join(d.dir, name);
74
+ try { if (fs.lstatSync(target).isSymbolicLink()) { fs.rmSync(target, { force: true }); } } catch { /* absent */ }
75
+ const tmp = path.join(d.dir, `.${name}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
76
+ let fd = null;
77
+ try {
78
+ fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
79
+ fs.writeSync(fd, body);
80
+ fs.fsyncSync(fd);
81
+ fs.closeSync(fd); fd = null;
82
+ fs.renameSync(tmp, target);
83
+ } catch (e) {
84
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* best effort */ } }
85
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
86
+ return refuse('write-failed', String((e && e.message) || e).slice(0, 160));
87
+ }
88
+ return { ok: true, path: target };
89
+ } catch (e) {
90
+ return refuse('write-failed', String((e && e.message) || e).slice(0, 160));
91
+ }
92
+ }
93
+
94
+ export function writeStoreJson(root, rel, value) { return writeStoreFile(root, rel, JSON.stringify(value, null, 2) + '\n'); }
95
+
96
+ /** Read one store file. Never follows a link. Returns { ok, text } or { ok:false, code }. */
97
+ export function readStoreFile(root, rel) {
98
+ const segs = segments(rel);
99
+ if (!segs) return refuse('bad-path', 'store paths are safe relative names');
100
+ const file = path.join(storeRoot(root), ...segs);
101
+ try {
102
+ const st = fs.lstatSync(file);
103
+ if (st.isSymbolicLink() || !st.isFile()) return refuse('not-a-regular-file', rel);
104
+ if (st.size > MAX_JSON_BYTES) return refuse('too-large', rel);
105
+ return { ok: true, text: fs.readFileSync(file, 'utf8') };
106
+ } catch (e) {
107
+ return refuse(e && e.code === 'ENOENT' ? 'missing' : 'unreadable', rel);
108
+ }
109
+ }
110
+
111
+ export function readStoreJson(root, rel) {
112
+ const r = readStoreFile(root, rel);
113
+ if (!r.ok) return r;
114
+ try { return { ok: true, value: JSON.parse(r.text) }; } catch { return refuse('corrupt-json', rel); }
115
+ }
116
+
117
+ /** Persist one built bundle set: manifest + every package text. */
118
+ export function writeBundle(root, bundle) {
119
+ const { manifest, pack } = bundle;
120
+ const dir = `bundles/${manifest.contextHash}`;
121
+ const written = [];
122
+ for (const p of pack.packages) {
123
+ const r = writeStoreFile(root, `${dir}/context-${p.index}.txt`, p.text);
124
+ if (!r.ok) return r;
125
+ written.push(r.path);
126
+ }
127
+ const m = writeStoreJson(root, `${dir}/manifest.json`, manifest);
128
+ if (!m.ok) return m;
129
+ written.push(m.path);
130
+ return { ok: true, paths: written, contextHash: manifest.contextHash };
131
+ }
132
+
133
+ export function readManifest(root, contextHash) {
134
+ if (!/^[0-9a-f]{64}$/.test(String(contextHash))) return refuse('bad-hash', 'a context hash is 64 hex characters');
135
+ return readStoreJson(root, `bundles/${contextHash}/manifest.json`);
136
+ }
137
+
138
+ /** Read a package's stored text and prove it still matches the hash in its manifest. */
139
+ export function readPackageText(root, manifest, index = 1) {
140
+ const pk = manifest.context.packages.find(p => p.index === index);
141
+ if (!pk) return refuse('no-such-package', String(index));
142
+ const r = readStoreFile(root, `bundles/${manifest.contextHash}/context-${index}.txt`);
143
+ if (!r.ok) return r;
144
+ if (sha256(r.text) !== pk.sha256) return refuse('hash-mismatch', `context-${index}.txt does not match the manifest`);
145
+ return { ok: true, text: r.text, package: pk };
146
+ }
147
+
148
+ /**
149
+ * Verify a stored bundle: manifest contract, every package text against its hash, and (with fileContents) every span
150
+ * against the current source. Any mismatch is an error; nothing is skipped quietly.
151
+ */
152
+ export function verifyStoredBundle(root, contextHash, { fileContents = null } = {}) {
153
+ const m = readManifest(root, contextHash);
154
+ if (!m.ok) return { ok: false, problems: [`manifest-${m.code}`] };
155
+ const problems = [...validateManifest(m.value, { fileContents })];
156
+ if (m.value.contextHash !== contextHash) problems.push('context-hash-differs-from-directory');
157
+ for (const pk of m.value.context.packages) {
158
+ const t = readPackageText(root, m.value, pk.index);
159
+ if (!t.ok) problems.push(`package-${pk.index}-${t.code}`);
160
+ }
161
+ return { ok: problems.length === 0, problems, manifest: m.value };
162
+ }
163
+
164
+ export function listStoredBundles(root) {
165
+ const dir = path.join(storeRoot(root), 'bundles');
166
+ try {
167
+ return fs.readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory() && /^[0-9a-f]{64}$/.test(e.name)).map(e => e.name).sort();
168
+ } catch { return []; }
169
+ }
170
+
171
+ /**
172
+ * Remove stored bundles that the current index does not reference and that are older than `olderThanDays`.
173
+ * A legal hold on the `context-intelligence` artifact blocks every deletion. `dryRun` reports without removing.
174
+ */
175
+ export function cleanupStore(root, { olderThanDays = 30, now = Date.now(), dryRun = false } = {}) {
176
+ const holds = loadLegalHolds(root);
177
+ const hold = isUnderHold(ARTIFACT_NAME, holds, now);
178
+ if (hold) return { ok: true, removed: [], kept: listStoredBundles(root), blockedByLegalHold: { owner: hold.owner, reason: hold.reason } };
179
+ const idx = readStoreJson(root, 'index.json');
180
+ const referenced = new Set(idx.ok ? (idx.value.entrypoints || []).map(e => e.contextHash).filter(Boolean) : []);
181
+ const removed = [];
182
+ const kept = [];
183
+ for (const h of listStoredBundles(root)) {
184
+ const dir = path.join(storeRoot(root), 'bundles', h);
185
+ let age;
186
+ try { age = (now - fs.statSync(dir).mtimeMs) / 86400000; } catch { kept.push(h); continue; }
187
+ if (referenced.has(h) || age <= olderThanDays) { kept.push(h); continue; }
188
+ if (!dryRun) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { kept.push(h); continue; } }
189
+ removed.push(h);
190
+ }
191
+ return { ok: true, removed, kept, blockedByLegalHold: null, dryRun };
192
+ }
193
+
194
+ /**
195
+ * Secure export: a single self-describing JSON document with the manifest and the package texts (never the source
196
+ * tree), refused for a bundle that fails verification. Honours legal hold trivially (it only reads).
197
+ */
198
+ export function exportBundle(root, contextHash) {
199
+ const v = verifyStoredBundle(root, contextHash);
200
+ if (!v.ok) return { ok: false, problems: v.problems };
201
+ const texts = v.manifest.context.packages.map(p => readPackageText(root, v.manifest, p.index).text);
202
+ return { ok: true, export: { schemaVersion: 'context-export/1', manifest: v.manifest, packages: texts } };
203
+ }
@@ -127,6 +127,8 @@ export const ARTIFACT_REGISTRY = [
127
127
  // directory-of-files shape re-keyed commit -> import.
128
128
  { name: 'runtime-observations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/lineage/observation-store.js (persistObservationImport)', note: 'FR-505 requires an observation store follow artifact encryption, RETENTION, RESET, access-control and no-egress rules. That is why this is `generated` (a plain `reset` MUST be able to delete it) rather than `operator-config`, and why it carries a real retentionClass — deliberately NOT the `remediation`/`legal-holds.json` no-retention call one section down, and deliberately NOT `provenance`\'s permanent-history call. This DOES stretch `generated`\'s usual definition — a rescan does not re-derive an import, the operator re-imports it — and that stretch is disclosed here rather than hidden: FR-505\'s explicit reset requirement breaks the tie. `confidential: true` is enforced by observation-store.js calling maybeEncryptForWrite/maybeDecryptForRead itself (posture/encryption-provider.js), the same per-writer opt-in compliance-evidence.json makes — the flag alone enforces nothing.' },
129
129
  { name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
130
+ { name: 'context-intelligence', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'src/context-intelligence/store.js (writeStoreFile)', note: 'Deterministic Context Intelligence store: context bundles (numbered source excerpts of the repository, so it holds source text and is treated like the source tree), manifests, review results, queue checkpoints, offline batch files and evaluation reports. Everything in it is regenerable by `context build` and `context review`. `context cleanup` removes unreferenced old bundles and refuses anything under a legal hold; a plain reset may delete the whole directory.' },
131
+ { name: 'context-intelligence.json', kind: 'file', classification: 'operator-config', source: 'src/context-intelligence/config.js (loadConfig)', note: 'Operator-authored configuration for the opt-in Deterministic Context Intelligence subsystem. Read only; reset leaves it alone.' },
130
132
  { name: 'reproducers', kind: 'dir', classification: 'operator-config', source: 'src/posture/proof-fabric/cli.js (loadReproducer)', note: 'Operator-supplied serialized reproducers read by `triage exploit --prove`. Input, never written by the scanner, so reset leaves it alone.' },
131
133
  { name: 'proofs', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/posture/proof-fabric/store.js (saveProof)', note: 'Universal Exploit Proof Fabric evidence: hashed records of exploit reproductions. Never holds plaintext credentials, per-run nonces or raw secrets (the runner redacts before persisting). Encrypted per the encryption policy via maybeEncryptForWrite, and purged by retention like any evidence-class artifact; a legal hold on `proofs` blocks the purge.' },
132
134
  { name: 'fleet-state.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet.js (loadFleetState/_saveFleetState)', note: 'Per-repository resume checkpoint for `fleet scan` — which fleet repositories are already complete, so a resumed run skips them. Scanner-managed; a plain reset may delete it.' },