@clear-capabilities/agentic-security-scanner 0.160.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 +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- 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/blast-radius.js +15 -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/remediation-ledger.js +10 -1
- 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
- package/src/sast/pipeline.js +51 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Unified configuration for the assurance features (CORE-004).
|
|
2
|
+
//
|
|
3
|
+
// Extends the configuration mechanism the scanner already has instead of adding
|
|
4
|
+
// a second one:
|
|
5
|
+
// - environment variables, `AGENTIC_SECURITY_*` (docs/reference/configuration.md),
|
|
6
|
+
// including the existing `AGENTIC_SECURITY_NO_<NAME>=1` kill-switch shape;
|
|
7
|
+
// - a per-project YAML file under `.agentic-security/` read through
|
|
8
|
+
// posture/state-dir.js and util/yaml.js, exactly as egress-policy.yml is.
|
|
9
|
+
//
|
|
10
|
+
// Precedence, highest first. A higher level always wins over a lower one, and the
|
|
11
|
+
// result says which level decided:
|
|
12
|
+
// 1. kill switch AGENTIC_SECURITY_NO_ASSURANCE=1 (all) or AGENTIC_SECURITY_NO_<FEATURE>=1
|
|
13
|
+
// 2. overrides explicit options from the caller (CLI flags)
|
|
14
|
+
// 3. environment AGENTIC_SECURITY_ASSURANCE_<FEATURE>=1|0
|
|
15
|
+
// 4. project file .agentic-security/assurance.yml
|
|
16
|
+
// 5. default every new feature defaults to OFF
|
|
17
|
+
//
|
|
18
|
+
// Two rules that follow from the trust model:
|
|
19
|
+
// - HIGH-RISK EXECUTION IS OPERATOR-ONLY. The project file lives inside the
|
|
20
|
+
// scanned repository, which is hostile input. A project file can never switch
|
|
21
|
+
// on a feature whose risk class is `high-risk-execution`; only the
|
|
22
|
+
// environment or an explicit override can. The refusal is disclosed, not silent.
|
|
23
|
+
// - INVALID CONFIGURATION FAILS CLOSED. An unknown feature, a non-boolean, or a
|
|
24
|
+
// limit that is not a finite number within its ceiling leaves that feature
|
|
25
|
+
// disabled and is reported in `errors`. It never falls through to a default
|
|
26
|
+
// that is more permissive than what the operator wrote.
|
|
27
|
+
//
|
|
28
|
+
// A disabled feature is inert: nothing in this module touches the scan or report
|
|
29
|
+
// pipeline, so default behaviour is unchanged.
|
|
30
|
+
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import { statePath } from '../state-dir.js';
|
|
33
|
+
import { load as loadYaml } from '../../util/yaml.js';
|
|
34
|
+
|
|
35
|
+
export const CONFIG_FILE = 'assurance.yml';
|
|
36
|
+
export const CONFIG_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
export const RISK_CLASSES = Object.freeze(['passive', 'model-network', 'high-risk-execution']);
|
|
39
|
+
|
|
40
|
+
// Every new feature. `platforms` is the set where the feature is supported at all;
|
|
41
|
+
// off it the result is a typed `unsupported`, with the note disclosed.
|
|
42
|
+
export const FEATURES = Object.freeze({
|
|
43
|
+
'verification-oracles': {
|
|
44
|
+
risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
|
|
45
|
+
platformNote: 'runtime oracles run through the confinement sandbox; isolation-required checks are only advertised on Linux',
|
|
46
|
+
},
|
|
47
|
+
'patch-negative-verification': {
|
|
48
|
+
risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
|
|
49
|
+
platformNote: 'replays the exploit against the original and the patched revision through the confinement sandbox, so it needs verification-oracles as well; an unproved isolation control blocks it',
|
|
50
|
+
},
|
|
51
|
+
'deployment-boundaries': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
52
|
+
'invariant-scenarios': {
|
|
53
|
+
risk: 'high-risk-execution', platforms: ['linux', 'darwin'],
|
|
54
|
+
platformNote: 'stateful scenarios execute target code and need the confinement sandbox',
|
|
55
|
+
},
|
|
56
|
+
'capability-enforcement': {
|
|
57
|
+
risk: 'high-risk-execution', platforms: ['linux'],
|
|
58
|
+
platformNote: 'runner-level filesystem, network and process enforcement is only advertised on Linux; macOS can supervise but not enforce',
|
|
59
|
+
},
|
|
60
|
+
'model-routing': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
61
|
+
'portfolio-assurance': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Finite limits with hard ceilings. `enforcedBy` names the helper in THIS module
|
|
65
|
+
// that actually enforces it; `null` means this layer only carries the number for
|
|
66
|
+
// a runner to enforce, and the disclosure says so rather than claiming otherwise.
|
|
67
|
+
export const LIMITS = Object.freeze({
|
|
68
|
+
timeoutMs: { default: 15_000, min: 1, max: 120_000, enforcedBy: 'withDeadline' },
|
|
69
|
+
retries: { default: 2, min: 0, max: 5, enforcedBy: 'retryBounded' },
|
|
70
|
+
maxOutputBytes: { default: 262_144, min: 1, max: 16 * 1024 * 1024, enforcedBy: 'capOutput' },
|
|
71
|
+
maxRequestBytes: { default: 262_144, min: 1, max: 16 * 1024 * 1024, enforcedBy: 'guardedModelCall' },
|
|
72
|
+
maxFileBytes: { default: 5 * 1024 * 1024, min: 1, max: 64 * 1024 * 1024, enforcedBy: 'readFileBounded' },
|
|
73
|
+
maxMemoryMiB: { default: 6144, min: 64, max: 65_536, enforcedBy: null },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const FEATURE_IDS = Object.keys(FEATURES);
|
|
77
|
+
const envName = (id) => id.toUpperCase().replace(/-/g, '_');
|
|
78
|
+
|
|
79
|
+
function parseBool(raw) {
|
|
80
|
+
if (raw === true || raw === '1' || raw === 'true') return true;
|
|
81
|
+
if (raw === false || raw === '0' || raw === 'false') return false;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function checkLimits(limits, where, errors) {
|
|
86
|
+
const out = {};
|
|
87
|
+
if (limits === undefined || limits === null) return out;
|
|
88
|
+
if (typeof limits !== 'object' || Array.isArray(limits)) { errors.push({ code: 'invalid-config', path: where, message: 'limits must be a mapping' }); return null; }
|
|
89
|
+
let bad = false;
|
|
90
|
+
for (const [k, v] of Object.entries(limits)) {
|
|
91
|
+
const spec = LIMITS[k];
|
|
92
|
+
if (!spec) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: `unknown limit '${k}'` }); bad = true; continue; }
|
|
93
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v)) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: 'must be a finite integer' }); bad = true; continue; }
|
|
94
|
+
if (v < spec.min || v > spec.max) { errors.push({ code: 'invalid-config', path: `${where}.${k}`, message: `must be between ${spec.min} and ${spec.max}` }); bad = true; continue; }
|
|
95
|
+
out[k] = v;
|
|
96
|
+
}
|
|
97
|
+
return bad ? null : out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readProjectFile(scanRoot, readFile, errors) {
|
|
101
|
+
let fp;
|
|
102
|
+
try { fp = statePath(scanRoot, CONFIG_FILE); } catch { return null; }
|
|
103
|
+
let raw;
|
|
104
|
+
try { raw = readFile(fp); } catch { return null; } // absent file is the normal case
|
|
105
|
+
let doc;
|
|
106
|
+
try { doc = loadYaml(raw); } catch (e) {
|
|
107
|
+
errors.push({ code: 'invalid-config', path: CONFIG_FILE, message: `not valid YAML: ${String(e.message).split('\n')[0]}` });
|
|
108
|
+
return { broken: true };
|
|
109
|
+
}
|
|
110
|
+
if (doc === undefined) return { features: {}, limits: {} };
|
|
111
|
+
if (typeof doc !== 'object' || Array.isArray(doc)) { errors.push({ code: 'invalid-config', path: CONFIG_FILE, message: 'must be a mapping' }); return { broken: true }; }
|
|
112
|
+
for (const k of Object.keys(doc)) {
|
|
113
|
+
if (!['version', 'features', 'limits'].includes(k)) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.${k}`, message: `unknown key '${k}'` });
|
|
114
|
+
}
|
|
115
|
+
if (doc.version !== undefined && doc.version !== CONFIG_VERSION) {
|
|
116
|
+
errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.version`, message: `unsupported version ${JSON.stringify(doc.version)} (supported: ${CONFIG_VERSION})` });
|
|
117
|
+
return { broken: true };
|
|
118
|
+
}
|
|
119
|
+
return doc;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the effective configuration.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {string} [opts.scanRoot]
|
|
127
|
+
* @param {object} [opts.env] defaults to process.env
|
|
128
|
+
* @param {object} [opts.overrides] `{ features: {id: bool}, limits: {...} }` from explicit flags
|
|
129
|
+
* @param {string} [opts.platform] defaults to process.platform
|
|
130
|
+
* @param {(p:string)=>string} [opts.readFile]
|
|
131
|
+
*/
|
|
132
|
+
export function resolveAssuranceConfig({ scanRoot, env = process.env, overrides = {}, platform = process.platform, readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
const file = readProjectFile(scanRoot, readFile, errors);
|
|
135
|
+
const fileOk = file && !file.broken ? file : null;
|
|
136
|
+
const globalKill = env.AGENTIC_SECURITY_NO_ASSURANCE === '1';
|
|
137
|
+
|
|
138
|
+
const fileFeatures = (fileOk && fileOk.features && typeof fileOk.features === 'object' && !Array.isArray(fileOk.features)) ? fileOk.features : {};
|
|
139
|
+
if (fileOk && fileOk.features !== undefined && fileFeatures !== fileOk.features) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features`, message: 'must be a mapping' });
|
|
140
|
+
for (const id of Object.keys(fileFeatures)) {
|
|
141
|
+
if (!FEATURES[id]) errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}`, message: `unknown feature '${id}'` });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const features = {};
|
|
145
|
+
for (const id of FEATURE_IDS) {
|
|
146
|
+
const spec = FEATURES[id];
|
|
147
|
+
const entry = { id, risk: spec.risk, enabled: false, source: 'default', reason: 'default: off', killed: false, notes: [] };
|
|
148
|
+
let invalid = false;
|
|
149
|
+
|
|
150
|
+
// 5/4: project file
|
|
151
|
+
const fe = fileFeatures[id];
|
|
152
|
+
if (fe !== undefined) {
|
|
153
|
+
if (typeof fe !== 'object' || fe === null || Array.isArray(fe)) { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}`, message: 'must be a mapping' }); invalid = true; }
|
|
154
|
+
else {
|
|
155
|
+
for (const k of Object.keys(fe)) if (!['enabled', 'limits'].includes(k)) { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}.${k}`, message: `unknown key '${k}'` }); invalid = true; }
|
|
156
|
+
if (fe.enabled !== undefined) {
|
|
157
|
+
const b = parseBool(fe.enabled);
|
|
158
|
+
if (typeof fe.enabled !== 'boolean') { errors.push({ code: 'invalid-config', path: `${CONFIG_FILE}.features.${id}.enabled`, message: 'must be true or false' }); invalid = true; }
|
|
159
|
+
else if (b && spec.risk === 'high-risk-execution') {
|
|
160
|
+
entry.notes.push('project file requested enablement; refused: high-risk execution can only be enabled by the operator through the environment or an explicit option');
|
|
161
|
+
entry.source = 'project-file-refused'; entry.reason = 'high-risk execution cannot be enabled by a project file';
|
|
162
|
+
} else { entry.enabled = b; entry.source = 'project-file'; entry.reason = `project file sets enabled=${b}`; }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 3: environment
|
|
167
|
+
const rawEnv = env[`AGENTIC_SECURITY_ASSURANCE_${envName(id)}`];
|
|
168
|
+
if (rawEnv !== undefined && rawEnv !== '') {
|
|
169
|
+
const b = parseBool(rawEnv);
|
|
170
|
+
if (b === undefined) { errors.push({ code: 'invalid-config', path: `AGENTIC_SECURITY_ASSURANCE_${envName(id)}`, message: `must be 1/true or 0/false, got ${JSON.stringify(rawEnv)}` }); invalid = true; }
|
|
171
|
+
else { entry.enabled = b; entry.source = 'env'; entry.reason = `environment sets enabled=${b}`; }
|
|
172
|
+
}
|
|
173
|
+
// 2: explicit overrides
|
|
174
|
+
const ov = overrides.features?.[id];
|
|
175
|
+
if (ov !== undefined) {
|
|
176
|
+
if (typeof ov !== 'boolean') { errors.push({ code: 'invalid-config', path: `overrides.features.${id}`, message: 'must be a boolean' }); invalid = true; }
|
|
177
|
+
else { entry.enabled = ov; entry.source = 'override'; entry.reason = `explicit option sets enabled=${ov}`; }
|
|
178
|
+
}
|
|
179
|
+
// invalid configuration fails closed
|
|
180
|
+
if (invalid) { entry.enabled = false; entry.source = 'invalid-config'; entry.reason = 'configuration for this feature is invalid; disabled'; }
|
|
181
|
+
// 1: kill switch
|
|
182
|
+
if (globalKill || env[`AGENTIC_SECURITY_NO_${envName(id)}`] === '1') {
|
|
183
|
+
entry.enabled = false; entry.killed = true; entry.source = 'kill-switch';
|
|
184
|
+
entry.reason = globalKill ? 'AGENTIC_SECURITY_NO_ASSURANCE=1' : `AGENTIC_SECURITY_NO_${envName(id)}=1`;
|
|
185
|
+
}
|
|
186
|
+
// platform disclosure
|
|
187
|
+
const supported = spec.platforms.includes(platform);
|
|
188
|
+
entry.platform = { supported, platform, note: spec.platformNote, supportedPlatforms: [...spec.platforms] };
|
|
189
|
+
features[id] = entry;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// limits: file -> overrides, each validated; a bad level is dropped whole
|
|
193
|
+
const limits = {};
|
|
194
|
+
for (const [k, spec] of Object.entries(LIMITS)) limits[k] = { value: spec.default, source: 'default', enforcedBy: spec.enforcedBy };
|
|
195
|
+
const apply = (obj, source, where) => {
|
|
196
|
+
const ok = checkLimits(obj, where, errors);
|
|
197
|
+
if (!ok) return;
|
|
198
|
+
for (const [k, v] of Object.entries(ok)) limits[k] = { value: v, source, enforcedBy: LIMITS[k].enforcedBy };
|
|
199
|
+
};
|
|
200
|
+
if (fileOk) apply(fileOk.limits, 'project-file', `${CONFIG_FILE}.limits`);
|
|
201
|
+
apply(overrides.limits, 'override', 'overrides.limits');
|
|
202
|
+
|
|
203
|
+
return { version: CONFIG_VERSION, features, limits, errors, killSwitch: globalKill };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Plain numeric limits for a caller that only wants the numbers. */
|
|
207
|
+
export function limitValues(config) {
|
|
208
|
+
return Object.fromEntries(Object.entries(config.limits).map(([k, v]) => [k, v.value]));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------- typed results
|
|
212
|
+
|
|
213
|
+
export const RESULT_STATUSES = Object.freeze(['ok', 'disabled', 'blocked', 'unsupported', 'degraded']);
|
|
214
|
+
export const RESULT_CODES = Object.freeze([
|
|
215
|
+
'disabled', 'kill-switch', 'platform-unsupported', 'invalid-config', 'egress-denied', 'limit-exceeded', 'timeout',
|
|
216
|
+
'missing-provider', 'missing-credential', 'missing-collector', 'missing-execution-backend', 'missing-dependency',
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
export function typed(status, code, reason, extra = {}) {
|
|
220
|
+
return { status, code, reason, ...extra };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The gate every new feature passes through. Never throws, never prompts, never reaches the network. */
|
|
224
|
+
export function featureStatus(config, id) {
|
|
225
|
+
const f = config?.features?.[id];
|
|
226
|
+
if (!f) return typed('blocked', 'invalid-config', `unknown feature '${id}'`);
|
|
227
|
+
if (f.killed) return typed('blocked', 'kill-switch', f.reason, { feature: id });
|
|
228
|
+
if (f.source === 'invalid-config') return typed('blocked', 'invalid-config', f.reason, { feature: id });
|
|
229
|
+
if (!f.enabled) return typed('disabled', 'disabled', f.reason, { feature: id });
|
|
230
|
+
if (!f.platform.supported) {
|
|
231
|
+
return typed('unsupported', 'platform-unsupported', `${id} is not supported on ${f.platform.platform}${f.platform.note ? `: ${f.platform.note}` : ''}`, { feature: id, supportedPlatforms: f.platform.supportedPlatforms });
|
|
232
|
+
}
|
|
233
|
+
return typed('ok', null, f.reason, { feature: id });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const REQUIREMENT_KINDS = Object.freeze({
|
|
237
|
+
provider: 'missing-provider',
|
|
238
|
+
credential: 'missing-credential',
|
|
239
|
+
collector: 'missing-collector',
|
|
240
|
+
'execution-backend': 'missing-execution-backend',
|
|
241
|
+
dependency: 'missing-dependency',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** A credential requirement that only checks that an environment variable is set. Never prompts, never reads its value into a result. */
|
|
245
|
+
export function envPresent(name, env = process.env) {
|
|
246
|
+
return () => typeof env[name] === 'string' && env[name].length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Evaluate declared requirements. A missing REQUIRED one blocks; a missing
|
|
251
|
+
* OPTIONAL one degrades. A probe that throws counts as missing. No requirement is
|
|
252
|
+
* ever satisfied by a fallback, a prompt or a cloud default.
|
|
253
|
+
*
|
|
254
|
+
* requirements: [{ kind, name, optional?, present: () => boolean }]
|
|
255
|
+
*/
|
|
256
|
+
export function evaluateRequirements(requirements = []) {
|
|
257
|
+
const missingRequired = [];
|
|
258
|
+
const missingOptional = [];
|
|
259
|
+
for (const r of requirements) {
|
|
260
|
+
let present = false;
|
|
261
|
+
try { present = !!r.present(); } catch { present = false; }
|
|
262
|
+
if (present) continue;
|
|
263
|
+
const item = { kind: r.kind, name: r.name, code: REQUIREMENT_KINDS[r.kind] || 'missing-dependency' };
|
|
264
|
+
(r.optional ? missingOptional : missingRequired).push(item);
|
|
265
|
+
}
|
|
266
|
+
return { missingRequired, missingOptional };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Run `fn` only when the feature is enabled, supported here and its required
|
|
271
|
+
* dependencies are present. Returns a typed result either way; `fn` is never
|
|
272
|
+
* called otherwise. Optional gaps yield `degraded` and `fn` receives them so it
|
|
273
|
+
* can skip that part of its work.
|
|
274
|
+
*/
|
|
275
|
+
export async function runFeature(config, id, { requirements = [] } = {}, fn) {
|
|
276
|
+
const gate = featureStatus(config, id);
|
|
277
|
+
if (gate.status !== 'ok') return gate;
|
|
278
|
+
const { missingRequired, missingOptional } = evaluateRequirements(requirements);
|
|
279
|
+
if (missingRequired.length) {
|
|
280
|
+
const first = missingRequired[0];
|
|
281
|
+
return typed('blocked', first.code, `${id} needs ${first.kind} '${first.name}', which is not available`, { feature: id, missing: missingRequired });
|
|
282
|
+
}
|
|
283
|
+
const value = await fn({ limits: limitValues(config), missingOptional });
|
|
284
|
+
if (missingOptional.length) return typed('degraded', missingOptional[0].code, `${id} ran without optional ${missingOptional.map(m => `${m.kind} '${m.name}'`).join(', ')}`, { feature: id, missing: missingOptional, value });
|
|
285
|
+
return typed('ok', null, gate.reason, { feature: id, value });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Operator-readable disclosure of every feature, its state and its platform support. */
|
|
289
|
+
export function describeAssuranceConfig(config) {
|
|
290
|
+
return {
|
|
291
|
+
killSwitch: config.killSwitch,
|
|
292
|
+
features: Object.values(config.features).map(f => ({
|
|
293
|
+
id: f.id, risk: f.risk, enabled: f.enabled, source: f.source, reason: f.reason,
|
|
294
|
+
platformSupported: f.platform.supported, platformNote: f.platform.supported ? null : f.platform.note, notes: f.notes,
|
|
295
|
+
})),
|
|
296
|
+
limits: Object.fromEntries(Object.entries(config.limits).map(([k, v]) => [k, { value: v.value, source: v.source, enforced: v.enforcedBy ? `by ${v.enforcedBy}` : 'not enforced by this layer (carried for the runner)' }])),
|
|
297
|
+
errors: config.errors,
|
|
298
|
+
};
|
|
299
|
+
}
|