@clear-capabilities/agentic-security-scanner 0.161.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.
- package/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -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 +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- 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/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -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/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- 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/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -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 +226 -0
- package/src/posture/verification/patch-promotion.js +113 -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 +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- 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,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
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Observation bindings, capability decisions, routing labels and release
|
|
2
|
+
// evidence (CORE-002), plus cross-record validation (duplicate ids, dangling
|
|
3
|
+
// references). Verification records live in verification-record.js.
|
|
4
|
+
//
|
|
5
|
+
// Lineage separation: an observation binding refers to a lineage runtime
|
|
6
|
+
// observation BY ID ONLY. It never embeds, imports or mutates lineage state;
|
|
7
|
+
// lineage owns the graph and this package owns the vulnerability-evidence claim
|
|
8
|
+
// that a given observation bears on a given hypothesis.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkDigest, checkCommit,
|
|
12
|
+
checkString, checkId, isPlainObject, isDigest,
|
|
13
|
+
} from './schema-kit.js';
|
|
14
|
+
import { semanticId, ID_PREFIXES } from './identity.js';
|
|
15
|
+
import { validateVerificationRecord, verificationClaimKey } from './verification-record.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- observation binding
|
|
18
|
+
|
|
19
|
+
export const OBSERVATION_BINDING_SCHEMA = 'agentic-security/observation-binding';
|
|
20
|
+
export const BINDING_PROVENANCE = Object.freeze(['static-config', 'runtime-observation', 'inferred']);
|
|
21
|
+
export const PATH_STATES = Object.freeze(['possible', 'blocked', 'unresolved', 'runtime-supported']);
|
|
22
|
+
export const OBSERVATION_COMPLETENESS = Object.freeze(['complete', 'sampled', 'unknown']);
|
|
23
|
+
|
|
24
|
+
const OB_ALLOWED = [
|
|
25
|
+
'schema', 'schemaVersion', 'id', 'hypothesisId', 'observationRef', 'boundaryRef', 'provenance', 'pathState',
|
|
26
|
+
'completeness', 'window', 'eventDiscriminator', 'migration', 'createdAt',
|
|
27
|
+
];
|
|
28
|
+
const OB_REQUIRED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'observationRef', 'provenance', 'pathState', 'completeness'];
|
|
29
|
+
|
|
30
|
+
// The semantic identity of a binding excludes the event discriminator.
|
|
31
|
+
export const OBSERVATION_BINDING_KEY_FIELDS = Object.freeze([
|
|
32
|
+
'hypothesisId', 'observationRef', 'boundaryRef', 'provenance', 'pathState', 'completeness', 'window',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Identity of WHAT the binding says, independent of which event delivered it. */
|
|
36
|
+
export function observationBindingKey(b) {
|
|
37
|
+
return semanticId('obkey', b, OBSERVATION_BINDING_KEY_FIELDS);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Id of this binding instance: the key plus the optional explicit event discriminator. */
|
|
41
|
+
export function observationBindingId(b) {
|
|
42
|
+
return semanticId(ID_PREFIXES.observationBinding, b, [...OBSERVATION_BINDING_KEY_FIELDS, 'eventDiscriminator']);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateObservationBinding(b) {
|
|
46
|
+
const g = guardObject(b);
|
|
47
|
+
const ctx = g.ctx;
|
|
48
|
+
if (!g.ok) return result(ctx);
|
|
49
|
+
if (!checkHeader(ctx, b, OBSERVATION_BINDING_SCHEMA)) return result(ctx);
|
|
50
|
+
checkFields(ctx, b, OB_ALLOWED, OB_REQUIRED);
|
|
51
|
+
checkString(ctx, 'hypothesisId', b.hypothesisId);
|
|
52
|
+
if (!isPlainObject(b.observationRef)) ctx.err('BAD_TYPE', 'observationRef', 'must be an object');
|
|
53
|
+
else {
|
|
54
|
+
for (const k of Object.keys(b.observationRef)) if (!['observationId', 'adapter'].includes(k)) ctx.err('UNKNOWN_FIELD', `observationRef.${k}`, 'not part of observationRef');
|
|
55
|
+
checkString(ctx, 'observationRef.observationId', b.observationRef.observationId);
|
|
56
|
+
}
|
|
57
|
+
if (b.boundaryRef !== undefined && b.boundaryRef !== null) checkString(ctx, 'boundaryRef', b.boundaryRef);
|
|
58
|
+
const provOk = checkEnum(ctx, 'provenance', b.provenance, BINDING_PROVENANCE);
|
|
59
|
+
const stateOk = checkEnum(ctx, 'pathState', b.pathState, PATH_STATES);
|
|
60
|
+
const compOk = checkEnum(ctx, 'completeness', b.completeness, OBSERVATION_COMPLETENESS);
|
|
61
|
+
if (b.window !== undefined && b.window !== null) {
|
|
62
|
+
if (!isPlainObject(b.window) || !Object.keys(b.window).every(k => ['start', 'end'].includes(k))) ctx.err('BAD_TYPE', 'window', 'must be {start, end}');
|
|
63
|
+
else { checkString(ctx, 'window.start', b.window.start); checkString(ctx, 'window.end', b.window.end); }
|
|
64
|
+
}
|
|
65
|
+
if (b.eventDiscriminator !== undefined && b.eventDiscriminator !== null) checkString(ctx, 'eventDiscriminator', b.eventDiscriminator);
|
|
66
|
+
if (provOk && stateOk && compOk) {
|
|
67
|
+
if (b.pathState === 'runtime-supported' && b.provenance !== 'runtime-observation') {
|
|
68
|
+
ctx.err('RULE_VIOLATION', 'pathState', `'runtime-supported' requires provenance 'runtime-observation'`);
|
|
69
|
+
}
|
|
70
|
+
if (b.pathState === 'blocked' && b.provenance === 'runtime-observation' && b.completeness !== 'complete') {
|
|
71
|
+
ctx.err('RULE_VIOLATION', 'pathState', `absence in a ${b.completeness} observation cannot establish 'blocked'`);
|
|
72
|
+
}
|
|
73
|
+
if (b.provenance === 'runtime-observation' && !(isPlainObject(b.window) && b.window.start && b.window.end)) {
|
|
74
|
+
ctx.err('RULE_VIOLATION', 'window', `a runtime observation binding must state its observation window`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
checkId(ctx, b, observationBindingId(b));
|
|
78
|
+
return result(ctx);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- capability decision
|
|
82
|
+
|
|
83
|
+
export const CAPABILITY_DECISION_SCHEMA = 'agentic-security/capability-decision';
|
|
84
|
+
export const CAPABILITY_KINDS = Object.freeze(['filesystem-read', 'filesystem-write', 'command', 'network', 'tool', 'delegation']);
|
|
85
|
+
export const CAPABILITY_DECISIONS = Object.freeze(['allow', 'deny', 'blocked', 'unsupported']);
|
|
86
|
+
export const CAPABILITY_MEDIATIONS = Object.freeze(['runner', 'proxy', 'in-process-policy', 'hook-advisory', 'none']);
|
|
87
|
+
|
|
88
|
+
const CD_ALLOWED = [
|
|
89
|
+
'schema', 'schemaVersion', 'id', 'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend',
|
|
90
|
+
'probeDigest', 'reason', 'migration', 'createdAt',
|
|
91
|
+
];
|
|
92
|
+
const CD_REQUIRED = ['schema', 'schemaVersion', 'id', 'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend', 'reason'];
|
|
93
|
+
|
|
94
|
+
export const CAPABILITY_DECISION_ID_FIELDS = Object.freeze([
|
|
95
|
+
'taskId', 'capability', 'subject', 'decision', 'mediation', 'enforced', 'backend', 'probeDigest', 'reason',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
export function capabilityDecisionId(d) {
|
|
99
|
+
return semanticId(ID_PREFIXES.capabilityDecision, d, CAPABILITY_DECISION_ID_FIELDS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateCapabilityDecision(d) {
|
|
103
|
+
const g = guardObject(d);
|
|
104
|
+
const ctx = g.ctx;
|
|
105
|
+
if (!g.ok) return result(ctx);
|
|
106
|
+
if (!checkHeader(ctx, d, CAPABILITY_DECISION_SCHEMA)) return result(ctx);
|
|
107
|
+
checkFields(ctx, d, CD_ALLOWED, CD_REQUIRED);
|
|
108
|
+
checkString(ctx, 'taskId', d.taskId);
|
|
109
|
+
checkEnum(ctx, 'capability', d.capability, CAPABILITY_KINDS);
|
|
110
|
+
checkString(ctx, 'subject', d.subject);
|
|
111
|
+
const decOk = checkEnum(ctx, 'decision', d.decision, CAPABILITY_DECISIONS);
|
|
112
|
+
const medOk = checkEnum(ctx, 'mediation', d.mediation, CAPABILITY_MEDIATIONS);
|
|
113
|
+
if (typeof d.enforced !== 'boolean') ctx.err('BAD_TYPE', 'enforced', 'must be a boolean');
|
|
114
|
+
if (d.backend !== null && d.backend !== undefined) checkString(ctx, 'backend', d.backend);
|
|
115
|
+
if (d.probeDigest !== undefined && d.probeDigest !== null) checkDigest(ctx, 'probeDigest', d.probeDigest);
|
|
116
|
+
checkString(ctx, 'reason', d.reason);
|
|
117
|
+
if (decOk && medOk && d.enforced === true) {
|
|
118
|
+
// A hook response or an in-process check explains a decision; it does not
|
|
119
|
+
// prove isolation. Only the runner or a proxy, with a named backend and an
|
|
120
|
+
// active probe, may claim enforcement.
|
|
121
|
+
if (!['runner', 'proxy'].includes(d.mediation)) ctx.err('RULE_VIOLATION', 'enforced', `mediation '${d.mediation}' cannot claim enforcement`);
|
|
122
|
+
if (!(typeof d.backend === 'string' && d.backend)) ctx.err('RULE_VIOLATION', 'backend', 'an enforced decision must name its backend');
|
|
123
|
+
if (!isDigest(d.probeDigest)) ctx.err('RULE_VIOLATION', 'probeDigest', 'an enforced decision must carry the digest of its active probe evidence');
|
|
124
|
+
if (!['allow', 'deny'].includes(d.decision)) ctx.err('RULE_VIOLATION', 'enforced', `decision '${d.decision}' cannot be enforced`);
|
|
125
|
+
}
|
|
126
|
+
checkId(ctx, d, capabilityDecisionId(d));
|
|
127
|
+
return result(ctx);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------- routing label
|
|
131
|
+
|
|
132
|
+
export const ROUTING_LABEL_SCHEMA = 'agentic-security/routing-label';
|
|
133
|
+
export const ROUTING_TASK_KINDS = Object.freeze(['discovery', 'triage', 'verification-planning', 'repair']);
|
|
134
|
+
export const ROUTING_OUTCOMES = Object.freeze(['correct', 'incorrect', 'unknown', 'delayed']);
|
|
135
|
+
export const ROUTING_LABEL_SOURCES = Object.freeze(['adjudication', 'trusted-execution', 'none']);
|
|
136
|
+
|
|
137
|
+
const RL_ALLOWED = [
|
|
138
|
+
'schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource', 'verificationRecordId', 'migration', 'createdAt',
|
|
139
|
+
];
|
|
140
|
+
const RL_REQUIRED = ['schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource'];
|
|
141
|
+
export const ROUTING_LABEL_ID_FIELDS = Object.freeze(['taskId', 'taskKind', 'stratum', 'model', 'outcome', 'labelSource', 'verificationRecordId']);
|
|
142
|
+
|
|
143
|
+
export function routingLabelId(l) {
|
|
144
|
+
return semanticId(ID_PREFIXES.routingLabel, l, ROUTING_LABEL_ID_FIELDS);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function validateRoutingLabel(l) {
|
|
148
|
+
const g = guardObject(l);
|
|
149
|
+
const ctx = g.ctx;
|
|
150
|
+
if (!g.ok) return result(ctx);
|
|
151
|
+
if (!checkHeader(ctx, l, ROUTING_LABEL_SCHEMA)) return result(ctx);
|
|
152
|
+
checkFields(ctx, l, RL_ALLOWED, RL_REQUIRED);
|
|
153
|
+
checkString(ctx, 'taskId', l.taskId);
|
|
154
|
+
checkEnum(ctx, 'taskKind', l.taskKind, ROUTING_TASK_KINDS);
|
|
155
|
+
checkString(ctx, 'stratum', l.stratum);
|
|
156
|
+
checkString(ctx, 'model', l.model);
|
|
157
|
+
const outOk = checkEnum(ctx, 'outcome', l.outcome, ROUTING_OUTCOMES);
|
|
158
|
+
const srcOk = checkEnum(ctx, 'labelSource', l.labelSource, ROUTING_LABEL_SOURCES);
|
|
159
|
+
if (l.verificationRecordId !== undefined && l.verificationRecordId !== null) {
|
|
160
|
+
if (typeof l.verificationRecordId !== 'string' || !l.verificationRecordId.startsWith('vrec:')) ctx.err('BAD_ID', 'verificationRecordId', `must be a verification record id ('vrec:...')`);
|
|
161
|
+
}
|
|
162
|
+
if (outOk && srcOk) {
|
|
163
|
+
// Delayed and unknown outcomes are retained, but they cannot carry a source
|
|
164
|
+
// that implies they were adjudicated; a decided label must name its source.
|
|
165
|
+
if (['correct', 'incorrect'].includes(l.outcome) && l.labelSource === 'none') {
|
|
166
|
+
ctx.err('RULE_VIOLATION', 'labelSource', `a '${l.outcome}' label needs an adjudication or trusted-execution source`);
|
|
167
|
+
}
|
|
168
|
+
if (['unknown', 'delayed'].includes(l.outcome) && l.labelSource !== 'none') {
|
|
169
|
+
ctx.err('RULE_VIOLATION', 'labelSource', `an '${l.outcome}' label has no decided source`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
checkId(ctx, l, routingLabelId(l));
|
|
173
|
+
return result(ctx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- release evidence
|
|
177
|
+
|
|
178
|
+
export const RELEASE_EVIDENCE_SCHEMA = 'agentic-security/release-evidence';
|
|
179
|
+
export const CLAIM_STATUSES = Object.freeze(['verified', 'failed', 'skipped', 'incomplete', 'unverified']);
|
|
180
|
+
|
|
181
|
+
const RE_ALLOWED = ['schema', 'schemaVersion', 'id', 'subject', 'claims', 'complete', 'migration', 'createdAt'];
|
|
182
|
+
const RE_REQUIRED = ['schema', 'schemaVersion', 'id', 'subject', 'claims', 'complete'];
|
|
183
|
+
export const RELEASE_EVIDENCE_ID_FIELDS = Object.freeze(['subject', 'claims', 'complete']);
|
|
184
|
+
|
|
185
|
+
export function releaseEvidenceId(r) {
|
|
186
|
+
return semanticId(ID_PREFIXES.releaseEvidence, r, RELEASE_EVIDENCE_ID_FIELDS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `complete` as the claims support it: every claim verified, no gaps, exact commit and bundle bound. */
|
|
190
|
+
export function deriveReleaseComplete(r) {
|
|
191
|
+
if (!isPlainObject(r?.subject) || !r.subject.commit || !r.subject.bundleDigest) return false;
|
|
192
|
+
if (!Array.isArray(r.claims) || r.claims.length === 0) return false;
|
|
193
|
+
return r.claims.every(c => isPlainObject(c) && c.status === 'verified' && (!Array.isArray(c.gaps) || c.gaps.length === 0));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function validateReleaseEvidence(r) {
|
|
197
|
+
const g = guardObject(r);
|
|
198
|
+
const ctx = g.ctx;
|
|
199
|
+
if (!g.ok) return result(ctx);
|
|
200
|
+
if (!checkHeader(ctx, r, RELEASE_EVIDENCE_SCHEMA)) return result(ctx);
|
|
201
|
+
checkFields(ctx, r, RE_ALLOWED, RE_REQUIRED);
|
|
202
|
+
if (!isPlainObject(r.subject)) ctx.err('BAD_TYPE', 'subject', 'must be an object');
|
|
203
|
+
else {
|
|
204
|
+
for (const k of Object.keys(r.subject)) if (!['commit', 'bundleDigest', 'policyDigest'].includes(k)) ctx.err('UNKNOWN_FIELD', `subject.${k}`, 'not part of subject');
|
|
205
|
+
checkCommit(ctx, 'subject.commit', r.subject.commit ?? null);
|
|
206
|
+
checkDigest(ctx, 'subject.bundleDigest', r.subject.bundleDigest ?? null, { nullable: true });
|
|
207
|
+
if (r.subject.policyDigest !== undefined && r.subject.policyDigest !== null) checkDigest(ctx, 'subject.policyDigest', r.subject.policyDigest);
|
|
208
|
+
}
|
|
209
|
+
if (typeof r.complete !== 'boolean') ctx.err('BAD_TYPE', 'complete', 'must be a boolean');
|
|
210
|
+
if (!Array.isArray(r.claims)) ctx.err('BAD_TYPE', 'claims', 'must be an array');
|
|
211
|
+
else {
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
r.claims.forEach((c, i) => {
|
|
214
|
+
const p = `claims[${i}]`;
|
|
215
|
+
if (!isPlainObject(c)) { ctx.err('BAD_TYPE', p, 'claim must be an object'); return; }
|
|
216
|
+
for (const k of Object.keys(c)) if (!['id', 'statement', 'status', 'evidenceRefs', 'gaps'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of a claim');
|
|
217
|
+
if (checkString(ctx, `${p}.id`, c.id)) {
|
|
218
|
+
if (seen.has(c.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `duplicate claim id '${c.id}'`);
|
|
219
|
+
seen.add(c.id);
|
|
220
|
+
}
|
|
221
|
+
checkString(ctx, `${p}.statement`, c.statement);
|
|
222
|
+
const stOk = checkEnum(ctx, `${p}.status`, c.status, CLAIM_STATUSES);
|
|
223
|
+
if (!Array.isArray(c.evidenceRefs)) ctx.err('BAD_TYPE', `${p}.evidenceRefs`, 'must be an array');
|
|
224
|
+
else c.evidenceRefs.forEach((ref, j) => {
|
|
225
|
+
if (typeof ref !== 'string' || !(ref.startsWith('vrec:') || isDigest(ref))) ctx.err('BAD_ID', `${p}.evidenceRefs[${j}]`, `must be a verification record id or a sha256 digest`);
|
|
226
|
+
});
|
|
227
|
+
if (!Array.isArray(c.gaps) || !c.gaps.every(x => typeof x === 'string' && x.trim())) ctx.err('BAD_TYPE', `${p}.gaps`, 'must be an array of non-empty strings');
|
|
228
|
+
if (stOk && Array.isArray(c.evidenceRefs) && Array.isArray(c.gaps)) {
|
|
229
|
+
if (c.status === 'verified' && c.evidenceRefs.length === 0) ctx.err('RULE_VIOLATION', `${p}.evidenceRefs`, `a 'verified' claim must cite evidence`);
|
|
230
|
+
if (c.status === 'verified' && c.gaps.length > 0) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a 'verified' claim cannot list gaps`);
|
|
231
|
+
if (c.status !== 'verified' && c.gaps.length === 0) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a '${c.status}' claim must say what is missing`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (typeof r.complete === 'boolean' && r.complete !== deriveReleaseComplete(r)) {
|
|
235
|
+
ctx.err('RULE_VIOLATION', 'complete', `claimed complete=${r.complete} but the claims and subject support complete=${deriveReleaseComplete(r)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
checkId(ctx, r, releaseEvidenceId(r));
|
|
239
|
+
return result(ctx);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------- record set
|
|
243
|
+
|
|
244
|
+
const VALIDATORS = Object.freeze({
|
|
245
|
+
verificationRecords: validateVerificationRecord,
|
|
246
|
+
observationBindings: validateObservationBinding,
|
|
247
|
+
capabilityDecisions: validateCapabilityDecision,
|
|
248
|
+
routingLabels: validateRoutingLabel,
|
|
249
|
+
releaseEvidence: validateReleaseEvidence,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validate a whole set: every record individually, then duplicate ids within and
|
|
254
|
+
* across types, dangling references, and the cross-record honesty rules.
|
|
255
|
+
* Input is `{ verificationRecords, observationBindings, capabilityDecisions, routingLabels, releaseEvidence }`,
|
|
256
|
+
* each an optional array.
|
|
257
|
+
*/
|
|
258
|
+
export function validateRecordSet(set) {
|
|
259
|
+
const ctx = makeCtx();
|
|
260
|
+
if (!isPlainObject(set)) { ctx.err('NOT_AN_OBJECT', '', 'record set must be an object'); return result(ctx); }
|
|
261
|
+
for (const k of Object.keys(set)) if (!VALIDATORS[k]) ctx.err('UNKNOWN_FIELD', k, `'${k}' is not a record collection`);
|
|
262
|
+
const allIds = new Map();
|
|
263
|
+
for (const [name, validate] of Object.entries(VALIDATORS)) {
|
|
264
|
+
const list = set[name];
|
|
265
|
+
if (list === undefined) continue;
|
|
266
|
+
if (!Array.isArray(list)) { ctx.err('BAD_TYPE', name, 'must be an array'); continue; }
|
|
267
|
+
list.forEach((rec, i) => {
|
|
268
|
+
const r = validate(rec);
|
|
269
|
+
for (const e of r.errors) ctx.err(e.code, `${name}[${i}]${e.path ? '.' + e.path : ''}`, e.message);
|
|
270
|
+
if (isPlainObject(rec) && typeof rec.id === 'string') {
|
|
271
|
+
if (allIds.has(rec.id)) ctx.err('DUPLICATE_ID', `${name}[${i}].id`, `id '${rec.id}' is already used by ${allIds.get(rec.id)}`);
|
|
272
|
+
else allIds.set(rec.id, `${name}[${i}]`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const vrecs = new Map((Array.isArray(set.verificationRecords) ? set.verificationRecords : []).filter(isPlainObject).map(r => [r.id, r]));
|
|
278
|
+
|
|
279
|
+
// two records with the same claim key but different outcomes are a conflict, not two facts
|
|
280
|
+
const byClaim = new Map();
|
|
281
|
+
for (const [id, r] of vrecs) {
|
|
282
|
+
const key = verificationClaimKey(r);
|
|
283
|
+
const prior = byClaim.get(key);
|
|
284
|
+
if (prior && prior.outcome !== r.outcome) ctx.err('RULE_VIOLATION', `verificationRecords`, `records '${prior.id}' and '${id}' make the same claim with different outcomes ('${prior.outcome}' vs '${r.outcome}')`);
|
|
285
|
+
byClaim.set(key, r);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [id, r] of vrecs) {
|
|
289
|
+
const rid = r.repair?.replayRecordId;
|
|
290
|
+
if (rid && !vrecs.has(rid)) ctx.err('DANGLING_REF', `verificationRecords.${id}.repair.replayRecordId`, `replay record '${rid}' is not in the set`);
|
|
291
|
+
}
|
|
292
|
+
(Array.isArray(set.observationBindings) ? set.observationBindings : []).filter(isPlainObject).forEach((b, i) => {
|
|
293
|
+
// hypothesis ids are findings' stable ids, not record ids; a binding is dangling only against explicit vrec refs
|
|
294
|
+
if (typeof b.hypothesisId === 'string' && b.hypothesisId.startsWith('vrec:') && !vrecs.has(b.hypothesisId)) {
|
|
295
|
+
ctx.err('DANGLING_REF', `observationBindings[${i}].hypothesisId`, `'${b.hypothesisId}' is not in the set`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
(Array.isArray(set.routingLabels) ? set.routingLabels : []).filter(isPlainObject).forEach((l, i) => {
|
|
299
|
+
if (l.verificationRecordId && !vrecs.has(l.verificationRecordId)) ctx.err('DANGLING_REF', `routingLabels[${i}].verificationRecordId`, `'${l.verificationRecordId}' is not in the set`);
|
|
300
|
+
});
|
|
301
|
+
(Array.isArray(set.releaseEvidence) ? set.releaseEvidence : []).filter(isPlainObject).forEach((rev, ri) => {
|
|
302
|
+
(Array.isArray(rev.claims) ? rev.claims : []).filter(isPlainObject).forEach((c, ci) => {
|
|
303
|
+
for (const ref of Array.isArray(c.evidenceRefs) ? c.evidenceRefs : []) {
|
|
304
|
+
if (typeof ref === 'string' && ref.startsWith('vrec:')) {
|
|
305
|
+
const v = vrecs.get(ref);
|
|
306
|
+
if (!v) ctx.err('DANGLING_REF', `releaseEvidence[${ri}].claims[${ci}].evidenceRefs`, `'${ref}' is not in the set`);
|
|
307
|
+
else if (c.status === 'verified' && !['confirmed', 'refuted'].includes(v.outcome)) {
|
|
308
|
+
ctx.err('RULE_VIOLATION', `releaseEvidence[${ri}].claims[${ci}]`, `claim is 'verified' but cites '${ref}' whose outcome is '${v.outcome}'`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
return result(ctx);
|
|
315
|
+
}
|