@clear-capabilities/agentic-security-scanner 0.161.0 → 0.162.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.
- package/CHANGELOG.md +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Baseline capture (CORE-001): what this checkout actually is, before the
|
|
2
|
+
// differentiation work changes it.
|
|
3
|
+
//
|
|
4
|
+
// Principles:
|
|
5
|
+
// - READ-ONLY. Capture reads source, runs read-only git and version queries, and
|
|
6
|
+
// returns a manifest. It writes nothing; the CLI writes only to a path that
|
|
7
|
+
// `assertSafeOutput` accepts, which is never inside an application source root.
|
|
8
|
+
// - NOTHING IS FABRICATED. Every environmental fact is `{status:'known', value}`
|
|
9
|
+
// or `{status:'unknown', reason}`. A tool that is missing, a bundle that is
|
|
10
|
+
// absent, a repository with no HEAD: each is recorded as unknown with the reason.
|
|
11
|
+
// - EXACT BYTES. Digests are over working-tree bytes, so uncommitted edits move
|
|
12
|
+
// them, and the dirty path list says which files differ from HEAD.
|
|
13
|
+
// - EVIDENCE IS BOUND TO WHAT IT COVERS. Each capability records the digest of
|
|
14
|
+
// every source and evidence file it cites. `evaluateBaseline` compares a fresh
|
|
15
|
+
// capture to a recorded one and invalidates exactly the capabilities whose cited
|
|
16
|
+
// files changed; unrelated user changes are kept, listed and never touched.
|
|
17
|
+
//
|
|
18
|
+
// The manifest is not deterministic across machines (it records HEAD, dirty paths
|
|
19
|
+
// and tool versions), so it is generated on demand and not committed.
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
25
|
+
import { STATE_DIR_NAME } from '../state-dir.js';
|
|
26
|
+
import { digestOf, digestOfBytes } from './identity.js';
|
|
27
|
+
import { RECOMMENDATION_MAPPINGS, CAPABILITY_INVENTORY } from './baseline-inventory.js';
|
|
28
|
+
|
|
29
|
+
export const BASELINE_SCHEMA = 'agentic-security/baseline-manifest';
|
|
30
|
+
export const BASELINE_VERSION = '1.0.0';
|
|
31
|
+
export const CAPABILITY_STATUSES = Object.freeze(['implemented', 'partial', 'unsupported', 'unmeasured']);
|
|
32
|
+
|
|
33
|
+
// Directories whose contents are application source: capture never writes here.
|
|
34
|
+
export const SOURCE_ROOTS = Object.freeze(['scanner/src', 'scanner/bin', 'scanner/scripts', 'hooks', 'commands', 'agents', 'scripts']);
|
|
35
|
+
// What the source digest covers.
|
|
36
|
+
export const SOURCE_DIGEST_PATHS = Object.freeze(['scanner/src', 'scanner/bin', 'scanner/package.json', 'scanner/package-lock.json']);
|
|
37
|
+
const BUNDLE_PATH = 'scanner/dist/agentic-security.mjs';
|
|
38
|
+
|
|
39
|
+
const known = (value) => ({ status: 'known', value });
|
|
40
|
+
const unknown = (reason) => ({ status: 'unknown', reason });
|
|
41
|
+
|
|
42
|
+
function defaultExec(cmd, args, { cwd, timeoutMs = 15_000 } = {}) {
|
|
43
|
+
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs, env: cmd === 'git' ? hardenGitEnv() : process.env, maxBuffer: 64 * 1024 * 1024 });
|
|
44
|
+
if (r.error) return { status: null, stdout: '', stderr: String(r.error.code || r.error.message) };
|
|
45
|
+
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function git(runCmd, root, args) {
|
|
49
|
+
return runCmd('git', hardenGitArgs(args), { cwd: root });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fileDigest(root, rel) {
|
|
53
|
+
try {
|
|
54
|
+
const st = fs.lstatSync(path.join(root, rel));
|
|
55
|
+
if (!st.isFile()) return null;
|
|
56
|
+
return digestOfBytes(fs.readFileSync(path.join(root, rel)));
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tool(runCmd, root, cmd, args) {
|
|
61
|
+
const r = runCmd(cmd, args, { cwd: root });
|
|
62
|
+
if (r.status !== 0 || !r.stdout.trim()) return unknown(`${cmd} ${args.join(' ')} did not report a version${r.stderr ? ` (${String(r.stderr).trim().split('\n')[0]})` : ''}`);
|
|
63
|
+
return known(r.stdout.trim().split('\n')[0]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse `git status --porcelain=v1 -z`. Renames carry two paths; both are reported. */
|
|
67
|
+
export function parsePorcelain(out) {
|
|
68
|
+
const parts = out.split('\0').filter(Boolean);
|
|
69
|
+
const entries = [];
|
|
70
|
+
for (let i = 0; i < parts.length; i++) {
|
|
71
|
+
const rec = parts[i];
|
|
72
|
+
const xy = rec.slice(0, 2); const p = rec.slice(3);
|
|
73
|
+
if (xy[0] === 'R' || xy[0] === 'C') { entries.push({ path: p, xy }); i++; if (parts[i]) entries.push({ path: parts[i], xy: 'R-from' }); continue; }
|
|
74
|
+
entries.push({ path: p, xy });
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function classify(xy) {
|
|
80
|
+
if (xy === '??') return 'untracked';
|
|
81
|
+
if (xy.includes('D')) return 'deleted';
|
|
82
|
+
if (xy === 'R-from') return 'renamed-from';
|
|
83
|
+
if (xy[0] !== ' ' && xy[1] !== ' ') return 'staged-and-modified';
|
|
84
|
+
if (xy[0] !== ' ') return 'staged';
|
|
85
|
+
return 'modified';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectSourceFiles(runCmd, root) {
|
|
89
|
+
const r = git(runCmd, root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...SOURCE_DIGEST_PATHS]);
|
|
90
|
+
if (r.status !== 0) return null;
|
|
91
|
+
return [...new Set(r.stdout.split('\0').filter(Boolean))].sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Capture the baseline. `exec` and `now` are injectable so tests can prove the
|
|
96
|
+
* unknown paths and stay clock-free; `inventory`/`mappings` default to the real ones.
|
|
97
|
+
*/
|
|
98
|
+
export function captureBaseline({
|
|
99
|
+
root, exec: runCmd = defaultExec, now = () => new Date().toISOString(),
|
|
100
|
+
inventory = CAPABILITY_INVENTORY, mappings = RECOMMENDATION_MAPPINGS,
|
|
101
|
+
} = {}) {
|
|
102
|
+
if (!root) throw new Error('captureBaseline needs a root');
|
|
103
|
+
const problems = [];
|
|
104
|
+
const abs = path.resolve(root);
|
|
105
|
+
|
|
106
|
+
// ---- git state
|
|
107
|
+
const headR = git(runCmd, abs, ['rev-parse', 'HEAD']);
|
|
108
|
+
const head = headR.status === 0 && /^[0-9a-f]{40,64}$/.test(headR.stdout.trim()) ? known(headR.stdout.trim()) : unknown('no git HEAD could be resolved');
|
|
109
|
+
const branchR = git(runCmd, abs, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
110
|
+
const branch = branchR.status === 0 ? known(branchR.stdout.trim()) : unknown('branch could not be resolved');
|
|
111
|
+
const statusR = git(runCmd, abs, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
112
|
+
let dirty;
|
|
113
|
+
if (statusR.status !== 0) dirty = unknown('git status failed; the worktree state is not known');
|
|
114
|
+
else {
|
|
115
|
+
dirty = known(parsePorcelain(statusR.stdout).map(e => ({ path: e.path, change: classify(e.xy), digest: fileDigest(abs, e.path) })).sort((x, y) => x.path.localeCompare(y.path)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- digests
|
|
119
|
+
const files = collectSourceFiles(runCmd, abs);
|
|
120
|
+
let sourceDigest;
|
|
121
|
+
if (!files) sourceDigest = unknown('source file list unavailable (git ls-files failed)');
|
|
122
|
+
else {
|
|
123
|
+
const lines = [];
|
|
124
|
+
for (const f of files) { const d = fileDigest(abs, f); if (d) lines.push(`${f}\0${d}`); }
|
|
125
|
+
sourceDigest = known({ digest: digestOf(lines), fileCount: lines.length, covers: [...SOURCE_DIGEST_PATHS] });
|
|
126
|
+
}
|
|
127
|
+
const bundleBytes = fileDigest(abs, BUNDLE_PATH);
|
|
128
|
+
let bundle;
|
|
129
|
+
if (!bundleBytes) bundle = unknown(`${BUNDLE_PATH} is not present in this checkout`);
|
|
130
|
+
else {
|
|
131
|
+
let sidecar = null;
|
|
132
|
+
try { sidecar = fs.readFileSync(path.join(abs, `${BUNDLE_PATH}.sha256`), 'utf8').trim().split(/\s+/)[0] || null; } catch { /* no sidecar */ }
|
|
133
|
+
bundle = known({ path: BUNDLE_PATH, digest: bundleBytes, sidecar: sidecar ? `sha256:${sidecar}` : null, sidecarMatches: sidecar ? `sha256:${sidecar}` === bundleBytes : null });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- versions
|
|
137
|
+
let pkg = null;
|
|
138
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(abs, 'scanner/package.json'), 'utf8')); } catch { /* reported below */ }
|
|
139
|
+
const versions = {
|
|
140
|
+
node: known(process.version),
|
|
141
|
+
platform: known(`${process.platform}-${process.arch}`),
|
|
142
|
+
scanner: pkg?.version ? known(pkg.version) : unknown('scanner/package.json has no readable version'),
|
|
143
|
+
npm: tool(runCmd, abs, 'npm', ['--version']),
|
|
144
|
+
git: tool(runCmd, abs, 'git', ['--version']),
|
|
145
|
+
python3: tool(runCmd, abs, 'python3', ['--version']),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---- feature entry points that exist now
|
|
149
|
+
const entryPoints = {
|
|
150
|
+
packageBin: pkg?.bin ? known(pkg.bin) : unknown('no bin map in scanner/package.json'),
|
|
151
|
+
npmScripts: pkg?.scripts ? known(Object.keys(pkg.scripts).sort()) : unknown('no scripts in scanner/package.json'),
|
|
152
|
+
commands: listDir(abs, 'commands', f => f.endsWith('.md')),
|
|
153
|
+
hooks: listDir(abs, 'hooks', f => f.endsWith('.js') && !f.endsWith('.test.js')),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---- seven recommendation mappings, each extension point verified
|
|
157
|
+
const recMappings = mappings.map(m => {
|
|
158
|
+
const ext = m.extensionPoints.map(p => ({ path: p, exists: exists(abs, p) }));
|
|
159
|
+
for (const e of ext) if (!e.exists) problems.push(`mapping ${m.n}: extension point '${e.path}' does not exist`);
|
|
160
|
+
return { n: m.n, title: m.title, priority: m.priority, slice: m.slice, extensionPoints: ext };
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---- capability inventory
|
|
164
|
+
const capabilities = inventory.map(c => {
|
|
165
|
+
const sourceDigests = {}; const evidenceDigests = {};
|
|
166
|
+
for (const p of c.source) sourceDigests[p] = pathDigest(abs, p);
|
|
167
|
+
for (const e of c.evidence) evidenceDigests[e.path] = pathDigest(abs, e.path);
|
|
168
|
+
const missing = [...Object.entries(sourceDigests), ...Object.entries(evidenceDigests)].filter(([, d]) => d === null).map(([p]) => p);
|
|
169
|
+
for (const p of missing) problems.push(`capability '${c.id}': path '${p}' does not exist`);
|
|
170
|
+
const symbols = c.entryPoints.map(ep => ({ ...ep, exported: symbolExported(abs, ep.path, ep.symbol) }));
|
|
171
|
+
for (const s of symbols) if (!s.exported) problems.push(`capability '${c.id}': '${s.symbol}' is not exported by ${s.path}`);
|
|
172
|
+
if (!CAPABILITY_STATUSES.includes(c.status)) problems.push(`capability '${c.id}': status '${c.status}' is not one of ${CAPABILITY_STATUSES.join(', ')}`);
|
|
173
|
+
if (c.status === 'implemented' && c.gap) problems.push(`capability '${c.id}': 'implemented' must not carry a gap`);
|
|
174
|
+
if (c.status !== 'implemented' && !c.gap) problems.push(`capability '${c.id}': '${c.status}' must say what is missing`);
|
|
175
|
+
if (!c.source.length) problems.push(`capability '${c.id}': no source path`);
|
|
176
|
+
if (!c.evidence.length) problems.push(`capability '${c.id}': no test or artifact`);
|
|
177
|
+
return {
|
|
178
|
+
id: c.id, workstream: c.workstream, title: c.title, status: c.status, gap: c.gap, prd: c.prd,
|
|
179
|
+
source: c.source, evidence: c.evidence, entryPoints: symbols,
|
|
180
|
+
pathsMissing: missing, sourceDigests, evidenceDigests,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const manifest = {
|
|
185
|
+
schema: BASELINE_SCHEMA, schemaVersion: BASELINE_VERSION,
|
|
186
|
+
repository: { head, branch, dirtyPaths: dirty },
|
|
187
|
+
digests: { source: sourceDigest, bundle },
|
|
188
|
+
versions, entryPoints,
|
|
189
|
+
recommendationMappings: recMappings,
|
|
190
|
+
capabilities,
|
|
191
|
+
problems,
|
|
192
|
+
};
|
|
193
|
+
manifest.baselineId = digestOf(manifest);
|
|
194
|
+
// The capture time is real but is not part of the identity.
|
|
195
|
+
manifest.capturedAt = now();
|
|
196
|
+
return manifest;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function exists(root, rel) { try { fs.accessSync(path.join(root, rel)); return true; } catch { return false; } }
|
|
200
|
+
|
|
201
|
+
function listDir(root, rel, keep) {
|
|
202
|
+
try { return known(fs.readdirSync(path.join(root, rel)).filter(keep).sort()); } catch { return unknown(`${rel}/ is not readable`); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Digest of a file, or of a directory as the digest of its files' digests; null when the path does not exist. */
|
|
206
|
+
function pathDigest(root, rel) {
|
|
207
|
+
const p = path.join(root, rel);
|
|
208
|
+
let st;
|
|
209
|
+
try { st = fs.lstatSync(p); } catch { return null; }
|
|
210
|
+
if (st.isFile()) return digestOfBytes(fs.readFileSync(p));
|
|
211
|
+
if (!st.isDirectory()) return null;
|
|
212
|
+
const lines = [];
|
|
213
|
+
const walk = (d, prefix) => {
|
|
214
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((x, y) => x.name.localeCompare(y.name))) {
|
|
215
|
+
if (e.name === STATE_DIR_NAME || e.name === 'node_modules') continue;
|
|
216
|
+
const full = path.join(d, e.name);
|
|
217
|
+
if (e.isDirectory()) walk(full, `${prefix}${e.name}/`);
|
|
218
|
+
else if (e.isFile()) lines.push(`${prefix}${e.name}\0${digestOfBytes(fs.readFileSync(full))}`);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
walk(p, '');
|
|
222
|
+
return digestOf(lines);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function symbolExported(root, rel, symbol) {
|
|
226
|
+
try {
|
|
227
|
+
const text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
228
|
+
return new RegExp(`^\\s*export\\s+(?:async\\s+)?(?:function\\*?|const|class)\\s+${symbol}\\b`, 'm').test(text)
|
|
229
|
+
|| new RegExp(`^\\s*export\\s*\\{[^}]*\\b${symbol}\\b`, 'm').test(text);
|
|
230
|
+
} catch { return false; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compare a recorded baseline with a fresh capture of the same checkout.
|
|
235
|
+
*
|
|
236
|
+
* - A capability is `invalidated` when any source or evidence file it cites
|
|
237
|
+
* changed or disappeared; it lists the changed paths. Otherwise it stays `valid`.
|
|
238
|
+
* - Dirty paths are classified: `retained` (already dirty at baseline, same bytes),
|
|
239
|
+
* `changedSince` (dirty at baseline, bytes now different), `introduced` (newly
|
|
240
|
+
* dirty), `resolved` (was dirty, now clean). Each is split into `applicable`
|
|
241
|
+
* (cited by some capability) and `unrelated`. Unrelated changes never invalidate
|
|
242
|
+
* anything and are always disclosed.
|
|
243
|
+
*/
|
|
244
|
+
export function evaluateBaseline(recorded, current) {
|
|
245
|
+
const capabilities = recorded.capabilities.map(rc => {
|
|
246
|
+
const cc = current.capabilities.find(x => x.id === rc.id);
|
|
247
|
+
const changed = [];
|
|
248
|
+
if (!cc) changed.push('(capability no longer inventoried)');
|
|
249
|
+
else {
|
|
250
|
+
for (const [p, d] of Object.entries(rc.sourceDigests)) if (cc.sourceDigests[p] !== d) changed.push(p);
|
|
251
|
+
for (const [p, d] of Object.entries(rc.evidenceDigests)) if (cc.evidenceDigests[p] !== d) changed.push(p);
|
|
252
|
+
}
|
|
253
|
+
return { id: rc.id, status: rc.status, validity: changed.length ? 'invalidated' : 'valid', changedPaths: changed };
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const cited = new Set();
|
|
257
|
+
const citedDirs = [];
|
|
258
|
+
for (const c of recorded.capabilities) for (const p of [...c.source, ...c.evidence.map(e => e.path)]) { cited.add(p); citedDirs.push(p.replace(/\/$/, '')); }
|
|
259
|
+
const isApplicable = (p) => citedDirs.some(c => p === c || p.startsWith(`${c}/`));
|
|
260
|
+
|
|
261
|
+
const rd = recorded.repository.dirtyPaths; const cd = current.repository.dirtyPaths;
|
|
262
|
+
const userChanges = { comparable: rd.status === 'known' && cd.status === 'known', retained: [], changedSince: [], introduced: [], resolved: [] };
|
|
263
|
+
if (userChanges.comparable) {
|
|
264
|
+
const before = new Map(rd.value.map(e => [e.path, e])); const after = new Map(cd.value.map(e => [e.path, e]));
|
|
265
|
+
const tag = (e, kind) => ({ path: e.path, change: e.change, scope: isApplicable(e.path) ? 'applicable' : 'unrelated', kind });
|
|
266
|
+
for (const [p, e] of after) {
|
|
267
|
+
const b = before.get(p);
|
|
268
|
+
if (!b) userChanges.introduced.push(tag(e, 'introduced'));
|
|
269
|
+
else if (b.digest === e.digest) userChanges.retained.push(tag(e, 'retained'));
|
|
270
|
+
else userChanges.changedSince.push(tag(e, 'changedSince'));
|
|
271
|
+
}
|
|
272
|
+
for (const [p, e] of before) if (!after.has(p)) userChanges.resolved.push(tag(e, 'resolved'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
276
|
+
return {
|
|
277
|
+
baselineId: recorded.baselineId,
|
|
278
|
+
headChanged: !same(recorded.repository.head, current.repository.head),
|
|
279
|
+
bundleChanged: !same(recorded.digests.bundle, current.digests.bundle),
|
|
280
|
+
sourceChanged: !same(recorded.digests.source, current.digests.source),
|
|
281
|
+
capabilities,
|
|
282
|
+
invalidated: capabilities.filter(c => c.validity === 'invalidated').map(c => c.id),
|
|
283
|
+
valid: capabilities.filter(c => c.validity === 'valid').map(c => c.id),
|
|
284
|
+
userChanges,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A manifest is only as trustworthy as its own shape: check statuses, unknown-marking and the id. */
|
|
289
|
+
export function validateBaseline(m) {
|
|
290
|
+
const errors = [];
|
|
291
|
+
if (!m || typeof m !== 'object') return { ok: false, errors: ['not an object'] };
|
|
292
|
+
if (m.schema !== BASELINE_SCHEMA) errors.push('schema mismatch');
|
|
293
|
+
if (!/^1\./.test(String(m.schemaVersion))) errors.push('unsupported schemaVersion');
|
|
294
|
+
const check = (name, v) => {
|
|
295
|
+
if (!v || (v.status !== 'known' && v.status !== 'unknown')) errors.push(`${name}: not a known/unknown fact`);
|
|
296
|
+
else if (v.status === 'unknown' && (!v.reason || 'value' in v)) errors.push(`${name}: an unknown fact needs a reason and no value`);
|
|
297
|
+
else if (v.status === 'known' && !('value' in v)) errors.push(`${name}: a known fact needs a value`);
|
|
298
|
+
};
|
|
299
|
+
check('head', m.repository?.head); check('branch', m.repository?.branch); check('dirtyPaths', m.repository?.dirtyPaths);
|
|
300
|
+
check('digests.source', m.digests?.source); check('digests.bundle', m.digests?.bundle);
|
|
301
|
+
for (const [k, v] of Object.entries(m.versions || {})) check(`versions.${k}`, v);
|
|
302
|
+
if (!Array.isArray(m.recommendationMappings) || m.recommendationMappings.length !== 7) errors.push('exactly seven recommendation mappings are required');
|
|
303
|
+
for (const c of m.capabilities || []) if (!CAPABILITY_STATUSES.includes(c.status)) errors.push(`capability ${c.id}: bad status`);
|
|
304
|
+
const { baselineId, capturedAt, ...rest } = m;
|
|
305
|
+
if (baselineId !== digestOf(rest)) errors.push('baselineId does not match the manifest content');
|
|
306
|
+
return { ok: errors.length === 0, errors };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Where a baseline may be written. Never inside an application source root, and
|
|
311
|
+
* never over a file git tracks (which would change application source).
|
|
312
|
+
*/
|
|
313
|
+
export function assertSafeOutput(root, outPath, runCmd = defaultExec) {
|
|
314
|
+
const abs = path.resolve(root); const out = path.resolve(outPath);
|
|
315
|
+
const rel = path.relative(abs, out);
|
|
316
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return { ok: true, reason: 'outside the repository' };
|
|
317
|
+
const inSource = SOURCE_ROOTS.some(r => rel === r || rel.startsWith(`${r}${path.sep}`));
|
|
318
|
+
if (inSource) return { ok: false, reason: `'${rel}' is inside an application source root` };
|
|
319
|
+
const tracked = git(runCmd, abs, ['ls-files', '--error-unmatch', '--', rel]);
|
|
320
|
+
if (tracked.status === 0) return { ok: false, reason: `'${rel}' is tracked by git; refusing to overwrite application files` };
|
|
321
|
+
return { ok: true, reason: 'untracked path outside the source roots' };
|
|
322
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Bounded I/O and the single route for new model/network calls (CORE-004).
|
|
2
|
+
//
|
|
3
|
+
// There is deliberately NO HTTP client in this module. `guardedModelCall` takes
|
|
4
|
+
// the transport as an argument (`call`), so it cannot become a way around the
|
|
5
|
+
// existing egress layer: before `call` is ever invoked, the request is
|
|
6
|
+
// 1. gated by the feature configuration (disabled, killed, unsupported),
|
|
7
|
+
// 2. checked for a configured endpoint (never defaulted, never a cloud fallback),
|
|
8
|
+
// 3. evaluated by egress/policy.js evaluateEgress(), BEFORE any payload is built,
|
|
9
|
+
// 4. redacted by egress/redact.js redactPayload(), so `call` only ever sees
|
|
10
|
+
// redacted text,
|
|
11
|
+
// 5. size-checked against the finite request limit.
|
|
12
|
+
// Only then is it run under a deadline with a bounded retry count, and its output
|
|
13
|
+
// is capped. Every decision is appended to the existing egress audit chain.
|
|
14
|
+
//
|
|
15
|
+
// Every limit named here is enforced by a function in this file; limits that this
|
|
16
|
+
// layer cannot enforce are disclosed as such by config.js, not claimed.
|
|
17
|
+
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import { evaluateEgress } from '../../egress/policy.js';
|
|
20
|
+
import { redactPayload } from '../../egress/redact.js';
|
|
21
|
+
import { recordEgressCall, payloadMetrics } from '../../egress/audit.js';
|
|
22
|
+
import { typed, featureStatus, evaluateRequirements, limitValues } from './config.js';
|
|
23
|
+
|
|
24
|
+
/** Read a file, refusing anything larger than `maxBytes`. Reads at most maxBytes+1, so a file that grows after the size check cannot exhaust memory. */
|
|
25
|
+
export function readFileBounded(filePath, maxBytes) {
|
|
26
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) return typed('blocked', 'invalid-config', 'maxBytes must be a positive integer');
|
|
27
|
+
let fd;
|
|
28
|
+
try {
|
|
29
|
+
fd = fs.openSync(filePath, 'r');
|
|
30
|
+
const st = fs.fstatSync(fd);
|
|
31
|
+
if (!st.isFile()) return typed('blocked', 'limit-exceeded', 'not a regular file');
|
|
32
|
+
if (st.size > maxBytes) return typed('blocked', 'limit-exceeded', `file is ${st.size} bytes, over the ${maxBytes} byte limit`, { bytes: st.size });
|
|
33
|
+
const buf = Buffer.alloc(maxBytes + 1);
|
|
34
|
+
const n = fs.readSync(fd, buf, 0, maxBytes + 1, 0);
|
|
35
|
+
if (n > maxBytes) return typed('blocked', 'limit-exceeded', `file grew past the ${maxBytes} byte limit while reading`);
|
|
36
|
+
return typed('ok', null, 'read', { text: buf.subarray(0, n).toString('utf8'), bytes: n });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return typed('blocked', 'missing-dependency', `cannot read ${filePath}: ${e.code || e.message}`);
|
|
39
|
+
} finally {
|
|
40
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Cap a string to `maxBytes` of UTF-8 without splitting a character. */
|
|
45
|
+
export function capOutput(text, maxBytes) {
|
|
46
|
+
const s = typeof text === 'string' ? text : String(text ?? '');
|
|
47
|
+
const bytes = Buffer.byteLength(s, 'utf8');
|
|
48
|
+
if (bytes <= maxBytes) return { text: s, truncated: false, bytes };
|
|
49
|
+
let cut = Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
|
|
50
|
+
if (cut.endsWith('�')) cut = cut.slice(0, -1); // a multi-byte character was split at the boundary
|
|
51
|
+
return { text: cut, truncated: true, bytes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run `fn(signal)` under a hard deadline. The timer is always cleared; a timeout aborts the signal and resolves to a typed result. */
|
|
55
|
+
export async function withDeadline(fn, ms) {
|
|
56
|
+
const ac = new AbortController();
|
|
57
|
+
let timer;
|
|
58
|
+
const timeout = new Promise((resolve) => {
|
|
59
|
+
timer = setTimeout(() => { ac.abort(); resolve(typed('blocked', 'timeout', `no result within ${ms} ms`)); }, ms);
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const run = Promise.resolve().then(() => fn(ac.signal)).then(
|
|
63
|
+
(value) => ({ ok: true, value }),
|
|
64
|
+
(error) => ({ ok: false, error }),
|
|
65
|
+
);
|
|
66
|
+
const winner = await Promise.race([run, timeout]);
|
|
67
|
+
if (winner && winner.ok === true) return typed('ok', null, 'completed', { value: winner.value });
|
|
68
|
+
if (winner && winner.ok === false) return { status: 'error', error: winner.error };
|
|
69
|
+
return winner;
|
|
70
|
+
} finally { clearTimeout(timer); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** At most `retries` extra attempts, and only for failures the callee marks `retryable`. */
|
|
74
|
+
export async function retryBounded(fn, { retries, backoffMs = 0 } = {}) {
|
|
75
|
+
const max = Math.max(0, Math.min(Number.isInteger(retries) ? retries : 0, 10));
|
|
76
|
+
let attempts = 0;
|
|
77
|
+
for (;;) {
|
|
78
|
+
attempts += 1;
|
|
79
|
+
const r = await fn(attempts);
|
|
80
|
+
if (r.status !== 'error' || !r.error?.retryable || attempts > max) return { ...r, attempts };
|
|
81
|
+
if (backoffMs > 0) await new Promise(res => setTimeout(res, Math.min(backoffMs * attempts, 5000)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The only way a new feature makes a model or network call.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} p
|
|
89
|
+
* @param {object} p.config resolveAssuranceConfig() result
|
|
90
|
+
* @param {string} p.featureId
|
|
91
|
+
* @param {string} p.scanRoot
|
|
92
|
+
* @param {string|null} p.endpoint MUST come from operator configuration; null yields `missing-provider`
|
|
93
|
+
* @param {string} p.purpose
|
|
94
|
+
* @param {string} p.text the prompt/payload, BEFORE redaction
|
|
95
|
+
* @param {string|null} [p.filePath]
|
|
96
|
+
* @param {string|null} [p.model]
|
|
97
|
+
* @param {Array} [p.requirements] extra requirements (e.g. a credential), evaluated before any call
|
|
98
|
+
* @param {(req:{endpoint:string,text:string,signal:AbortSignal,timeoutMs:number})=>Promise<string>} p.call injected transport
|
|
99
|
+
*/
|
|
100
|
+
export async function guardedModelCall({ config, featureId, scanRoot, endpoint, purpose, text, filePath = null, model = null, requirements = [], call }) {
|
|
101
|
+
const gate = featureStatus(config, featureId);
|
|
102
|
+
if (gate.status !== 'ok') return gate;
|
|
103
|
+
const limits = limitValues(config);
|
|
104
|
+
|
|
105
|
+
if (!endpoint || typeof endpoint !== 'string') {
|
|
106
|
+
return typed('blocked', 'missing-provider', `${featureId} has no model endpoint configured; there is no default and no cloud fallback`, { feature: featureId });
|
|
107
|
+
}
|
|
108
|
+
const { missingRequired } = evaluateRequirements(requirements);
|
|
109
|
+
if (missingRequired.length) {
|
|
110
|
+
const m = missingRequired[0];
|
|
111
|
+
return typed('blocked', m.code, `${featureId} needs ${m.kind} '${m.name}', which is not available`, { feature: featureId, missing: missingRequired });
|
|
112
|
+
}
|
|
113
|
+
if (typeof call !== 'function') return typed('blocked', 'missing-dependency', 'no transport supplied', { feature: featureId });
|
|
114
|
+
|
|
115
|
+
// egress policy BEFORE the prompt is built or any client is touched
|
|
116
|
+
const ctx = { scanRoot, purpose, endpoint, model, path: filePath };
|
|
117
|
+
const decision = evaluateEgress(ctx);
|
|
118
|
+
if (!decision.allowed) {
|
|
119
|
+
recordEgressCall({ scanRoot, decision, ctx });
|
|
120
|
+
return typed('blocked', 'egress-denied', decision.reason || 'egress policy denied the call', { feature: featureId, decision });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// redaction BEFORE request construction
|
|
124
|
+
const redacted = redactPayload({ text: String(text ?? ''), filePath, scanRoot });
|
|
125
|
+
const metrics = payloadMetrics(redacted.text);
|
|
126
|
+
if (metrics.byteCount > limits.maxRequestBytes) {
|
|
127
|
+
recordEgressCall({ scanRoot, decision: { ...decision, allowed: false, decision: 'deny', reason: 'request over maxRequestBytes' }, ctx });
|
|
128
|
+
return typed('blocked', 'limit-exceeded', `request is ${metrics.byteCount} bytes, over the ${limits.maxRequestBytes} byte limit`, { feature: featureId });
|
|
129
|
+
}
|
|
130
|
+
recordEgressCall({ scanRoot, decision, ctx, metrics });
|
|
131
|
+
|
|
132
|
+
const run = await retryBounded(
|
|
133
|
+
() => withDeadline((signal) => call({ endpoint, text: redacted.text, signal, timeoutMs: limits.timeoutMs }), limits.timeoutMs),
|
|
134
|
+
{ retries: limits.retries },
|
|
135
|
+
);
|
|
136
|
+
if (run.status === 'error') {
|
|
137
|
+
return typed('blocked', 'missing-dependency', `model call failed: ${String(run.error?.message || run.error)}`, { feature: featureId, attempts: run.attempts });
|
|
138
|
+
}
|
|
139
|
+
if (run.status !== 'ok') return { ...run, feature: featureId, attempts: run.attempts };
|
|
140
|
+
const out = capOutput(run.value, limits.maxOutputBytes);
|
|
141
|
+
const status = out.truncated ? 'degraded' : 'ok';
|
|
142
|
+
return typed(status, out.truncated ? 'limit-exceeded' : null, out.truncated ? `output truncated to ${limits.maxOutputBytes} bytes` : 'completed', {
|
|
143
|
+
feature: featureId, text: out.text, truncated: out.truncated, attempts: run.attempts, redactions: redacted.redactions,
|
|
144
|
+
});
|
|
145
|
+
}
|