@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/bin/agentic-security.js +87 -0
- 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 +1 -1
- 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 +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- 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 +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- 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 +5 -2
- 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 +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- 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/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 +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- 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 +1 -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/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Tamper-evident capability receipts (X-507).
|
|
2
|
+
//
|
|
3
|
+
// A receipt chain is the audit trail of ONE task run under ONE policy version:
|
|
4
|
+
//
|
|
5
|
+
// start the task identity, the manifest digest and policy version, the
|
|
6
|
+
// backend the runner used, the level it reached and the controls
|
|
7
|
+
// the runner OBSERVED (the active-probe states and probe digest),
|
|
8
|
+
// plus the requested/checked/enforced report built from them
|
|
9
|
+
// decision one per capability decision, carrying the CORE-002
|
|
10
|
+
// `capability-decision` record (decision, action, mediation,
|
|
11
|
+
// enforced flag, backend)
|
|
12
|
+
// outcome what happened to the action (exited, timeout, denied, not-run ...)
|
|
13
|
+
// end the seal: how many receipts came before it and the ids of every
|
|
14
|
+
// decision receipt, so removing one is visible even to a verifier
|
|
15
|
+
// who only has the envelope
|
|
16
|
+
//
|
|
17
|
+
// Each receipt carries the hash of the one before it (`prev`) and its own hash, so
|
|
18
|
+
// a missing, reordered or edited receipt breaks the chain at a named position.
|
|
19
|
+
// The sealed chain is then signed (Ed25519) in the SIGNER domain with the same
|
|
20
|
+
// self-issued trust label the evidence bundles carry. Three roles, three domains
|
|
21
|
+
// (sandbox/trust-domains.js):
|
|
22
|
+
//
|
|
23
|
+
// recorder verifier domain: writes authoritative evidence. A worker or target
|
|
24
|
+
// cannot create a recorder.
|
|
25
|
+
// signer signer domain: the only one that may read the signing key.
|
|
26
|
+
// workers neither. A worker cannot write a receipt file (the receipt
|
|
27
|
+
// directory is outside every write root and read-denied by the
|
|
28
|
+
// runner), cannot alter one (the signature), and cannot supply the
|
|
29
|
+
// key.
|
|
30
|
+
//
|
|
31
|
+
// What a receipt PROVES: that the verifier-side recorder observed these decisions
|
|
32
|
+
// and outcomes under this manifest and backend, and that nothing was changed
|
|
33
|
+
// afterwards. It does NOT prove independent certification (the issuer is the local
|
|
34
|
+
// install), and `fullyEnforced` is a label the verifier computes: it is true only
|
|
35
|
+
// for a complete, intact, current chain whose runner reached `enforced` with every
|
|
36
|
+
// control proved. A host-proved run (macOS) is never labelled enforced.
|
|
37
|
+
import crypto from 'node:crypto';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { canonicalJson, keyFingerprint, TRUST_BASES } from '../posture/evidence-bundle.js';
|
|
41
|
+
import { mayDo, RESOURCES } from '../sandbox/trust-domains.js';
|
|
42
|
+
import { sanitizeSubject } from './reasons.js';
|
|
43
|
+
import { toCapabilityDecisionRecord } from './records.js';
|
|
44
|
+
import { canonicalPath, overlaps } from './paths.js';
|
|
45
|
+
import { platformStatements } from './probes.js';
|
|
46
|
+
|
|
47
|
+
export const RECEIPT_SCHEMA = 'agentic-security/capability-receipt';
|
|
48
|
+
export const RECEIPT_CHAIN_SCHEMA = 'agentic-security/capability-receipt-chain';
|
|
49
|
+
export const RECEIPT_ENVELOPE_SCHEMA = 'agentic-security/capability-receipt-envelope';
|
|
50
|
+
export const RECEIPT_KINDS = Object.freeze(['start', 'decision', 'outcome', 'policy-change', 'end']);
|
|
51
|
+
export const MAX_RECEIPTS = 2000;
|
|
52
|
+
export const MAX_ENVELOPE_BYTES = 4 * 1024 * 1024;
|
|
53
|
+
const GENESIS = '0'.repeat(64);
|
|
54
|
+
|
|
55
|
+
const sha = (v) => crypto.createHash('sha256').update(canonicalJson(v)).digest('hex');
|
|
56
|
+
|
|
57
|
+
function assertDomain(domain, resource, action, role) {
|
|
58
|
+
if (!mayDo(domain, resource, action)) {
|
|
59
|
+
throw Object.assign(new Error(`the ${String(domain)} domain may not act as the receipt ${role}`), { code: 'domain-denied' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- recording
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} o
|
|
67
|
+
* @param {string} o.domain must be a domain that may write authoritative evidence (the verifier)
|
|
68
|
+
* @param {{manifest:object,binding:object}} o.bound
|
|
69
|
+
* @param {object|null} o.observed what the runner observed: `{backend, level, enforced, probeDigest, controls, report}`; null when the run never reached a backend
|
|
70
|
+
*/
|
|
71
|
+
export function createReceiptRecorder({ domain, bound, observed, now = () => new Date().toISOString() }) {
|
|
72
|
+
assertDomain(domain, RESOURCES.AUTHORITATIVE_EVIDENCE, 'write', 'recorder');
|
|
73
|
+
const b = bound.binding;
|
|
74
|
+
const m = bound.manifest;
|
|
75
|
+
const receipts = [];
|
|
76
|
+
let sealed = false;
|
|
77
|
+
|
|
78
|
+
function push(kind, body) {
|
|
79
|
+
if (sealed) throw Object.assign(new Error('the receipt chain is sealed'), { code: 'sealed' });
|
|
80
|
+
if (receipts.length >= MAX_RECEIPTS) throw Object.assign(new Error('receipt limit reached'), { code: 'receipt-limit' });
|
|
81
|
+
const core = {
|
|
82
|
+
schema: RECEIPT_SCHEMA, seq: receipts.length, kind, taskId: b.taskId, policyVersion: b.policyVersion,
|
|
83
|
+
manifestDigest: b.digest, prev: receipts.length ? receipts[receipts.length - 1].hash : GENESIS, at: now(), body,
|
|
84
|
+
};
|
|
85
|
+
const receipt = Object.freeze({ ...core, hash: sha(core) });
|
|
86
|
+
receipts.push(receipt);
|
|
87
|
+
return receipt;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
push('start', {
|
|
91
|
+
revision: b.revision,
|
|
92
|
+
requested: {
|
|
93
|
+
filesystemRead: m.filesystem.read.length, filesystemWrite: m.filesystem.write.length, commands: m.commands.length,
|
|
94
|
+
network: m.network.length, tools: m.tools.length, delegation: m.delegation.allow,
|
|
95
|
+
},
|
|
96
|
+
observed: observed ? {
|
|
97
|
+
backend: observed.backend ?? null, level: observed.level ?? 'none', enforced: observed.enforced === true,
|
|
98
|
+
probeDigest: observed.probeDigest ?? null, controls: observed.controls ?? {},
|
|
99
|
+
} : null,
|
|
100
|
+
report: observed?.report ?? null,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
decision(record) {
|
|
105
|
+
if (!record || record.schema !== 'agentic-security/capability-decision') throw Object.assign(new Error('a capability-decision record is required'), { code: 'bad-record' });
|
|
106
|
+
return push('decision', { record });
|
|
107
|
+
},
|
|
108
|
+
outcome(o) {
|
|
109
|
+
return push('outcome', {
|
|
110
|
+
action: sanitizeSubject(o.action ?? '', 200), outcome: sanitizeSubject(o.outcome ?? '', 60),
|
|
111
|
+
status: o.status ?? null, code: o.code ?? null, exitCode: Number.isInteger(o.exitCode) ? o.exitCode : null,
|
|
112
|
+
cleanupComplete: o.cleanupComplete === undefined ? null : o.cleanupComplete === true,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
policyChange(entry) { return push('policy-change', { entry }); },
|
|
116
|
+
seal() {
|
|
117
|
+
const decisionIds = receipts.filter((r) => r.kind === 'decision').map((r) => r.body.record.id);
|
|
118
|
+
push('end', { count: receipts.length, decisionIds });
|
|
119
|
+
sealed = true;
|
|
120
|
+
const head = receipts[receipts.length - 1].hash;
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
schema: RECEIPT_CHAIN_SCHEMA,
|
|
123
|
+
binding: Object.freeze({ taskId: b.taskId, revision: b.revision, policyVersion: b.policyVersion, manifestDigest: b.digest }),
|
|
124
|
+
receipts: Object.freeze(receipts.slice()), count: receipts.length, head,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a sealed chain from a `runCapabilityTask` result. Every decision the run
|
|
132
|
+
* produced becomes a receipt; a run that never reached a backend still produces a
|
|
133
|
+
* chain (start, its denial, an outcome of `not-run`, end), so a refusal is as
|
|
134
|
+
* auditable as a run, and its `observed` is null so it can never read as enforced.
|
|
135
|
+
*/
|
|
136
|
+
export function receiptsFromRun({ domain, bound, result, now }) {
|
|
137
|
+
const r = result || {};
|
|
138
|
+
const observed = r.report ? {
|
|
139
|
+
backend: r.report.backend ?? r.backend ?? null, level: r.report.level ?? r.level ?? 'none', enforced: r.enforced === true,
|
|
140
|
+
probeDigest: r.report.probeDigest ?? null,
|
|
141
|
+
controls: Object.fromEntries(Object.entries(r.report.controls || {}).map(([k, v]) => [k, v.state])),
|
|
142
|
+
report: r.report,
|
|
143
|
+
} : null;
|
|
144
|
+
const rec = createReceiptRecorder({ domain, bound, observed, now });
|
|
145
|
+
let records = Array.isArray(r.capabilityDecisions) ? r.capabilityDecisions : [];
|
|
146
|
+
if (!records.length && Array.isArray(r.decisions)) {
|
|
147
|
+
records = r.decisions
|
|
148
|
+
.map((d) => toCapabilityDecisionRecord(d, { mediation: 'runner', enforced: false, backend: r.backend ?? null }))
|
|
149
|
+
.filter((x) => x.ok).map((x) => x.record);
|
|
150
|
+
}
|
|
151
|
+
for (const record of records) rec.decision(record);
|
|
152
|
+
rec.outcome({
|
|
153
|
+
action: 'task', outcome: r.outcome ?? 'not-run', status: r.status ?? null, code: r.code ?? r.policyCode ?? null,
|
|
154
|
+
exitCode: r.exitCode, cleanupComplete: r.cleanup ? r.cleanup.complete : undefined,
|
|
155
|
+
});
|
|
156
|
+
return rec.seal();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- integrity
|
|
160
|
+
|
|
161
|
+
/** Recompute the chain: hashes, linkage, sequence and consistent task/policy binding. Pure; needs no key. */
|
|
162
|
+
export function verifyChainIntegrity(chain) {
|
|
163
|
+
const fail = (breakAt, reason) => ({ ok: false, breakAt, reason });
|
|
164
|
+
if (!chain || typeof chain !== 'object' || chain.schema !== RECEIPT_CHAIN_SCHEMA || !Array.isArray(chain.receipts) || !chain.binding) return fail(null, 'not a receipt chain');
|
|
165
|
+
if (chain.receipts.length > MAX_RECEIPTS) return fail(null, 'too many receipts');
|
|
166
|
+
let prev = GENESIS;
|
|
167
|
+
for (let i = 0; i < chain.receipts.length; i++) {
|
|
168
|
+
const r = chain.receipts[i];
|
|
169
|
+
if (!r || typeof r !== 'object' || r.schema !== RECEIPT_SCHEMA || !RECEIPT_KINDS.includes(r.kind)) return fail(i, 'malformed receipt');
|
|
170
|
+
const { hash, ...core } = r;
|
|
171
|
+
if (r.seq !== i) return fail(i, 'receipt out of sequence (missing or reordered)');
|
|
172
|
+
if (r.prev !== prev) return fail(i, 'chain link broken (a receipt was removed, reordered or replaced)');
|
|
173
|
+
if (sha(core) !== hash) return fail(i, 'receipt content does not match its hash (modified)');
|
|
174
|
+
if (r.taskId !== chain.binding.taskId || r.manifestDigest !== chain.binding.manifestDigest || r.policyVersion !== chain.binding.policyVersion) return fail(i, 'receipt is bound to a different task or policy');
|
|
175
|
+
prev = hash;
|
|
176
|
+
}
|
|
177
|
+
if (chain.count !== chain.receipts.length) return fail(null, 'receipt count does not match the chain');
|
|
178
|
+
if (chain.head !== prev) return fail(null, 'chain head does not match the last receipt');
|
|
179
|
+
return { ok: true, breakAt: null, reason: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether a chain that is intact is also COMPLETE: start first, seal last, every decision named by the seal present. */
|
|
183
|
+
export function assessCompleteness(chain, { requiredDecisionIds = [] } = {}) {
|
|
184
|
+
const reasons = [];
|
|
185
|
+
const rs = chain?.receipts || [];
|
|
186
|
+
if (!rs.length || rs[0].kind !== 'start') reasons.push('missing-start');
|
|
187
|
+
const last = rs[rs.length - 1];
|
|
188
|
+
if (!last || last.kind !== 'end') reasons.push('missing-seal');
|
|
189
|
+
const decisions = rs.filter((r) => r.kind === 'decision');
|
|
190
|
+
const have = new Set(decisions.map((r) => r.body?.record?.id));
|
|
191
|
+
if (last?.kind === 'end') {
|
|
192
|
+
if (last.body.count !== rs.length - 1) reasons.push('seal-count-mismatch');
|
|
193
|
+
const sealed = new Set(last.body.decisionIds || []);
|
|
194
|
+
for (const id of sealed) if (!have.has(id)) reasons.push('sealed-decision-missing');
|
|
195
|
+
for (const id of have) if (!sealed.has(id)) reasons.push('decision-not-sealed');
|
|
196
|
+
}
|
|
197
|
+
for (const id of requiredDecisionIds) if (!have.has(id)) reasons.push('required-decision-missing');
|
|
198
|
+
// A run that reached a backend must account for each kind of access it was
|
|
199
|
+
// granted. A refusal that never started has only the denial to account for.
|
|
200
|
+
const req = rs[0]?.body?.requested;
|
|
201
|
+
const obs = rs[0]?.body?.observed;
|
|
202
|
+
const kinds = new Set(decisions.map((r) => r.body.record.capability));
|
|
203
|
+
if (req && obs && obs.level !== 'none') {
|
|
204
|
+
if (req.commands > 0 && !kinds.has('command')) reasons.push('command-decision-missing');
|
|
205
|
+
if (req.filesystemRead > 0 && !kinds.has('filesystem-read')) reasons.push('filesystem-read-decision-missing');
|
|
206
|
+
if (req.filesystemWrite > 0 && !kinds.has('filesystem-write')) reasons.push('filesystem-write-decision-missing');
|
|
207
|
+
}
|
|
208
|
+
if (!rs.some((r) => r.kind === 'outcome')) reasons.push('missing-outcome');
|
|
209
|
+
return { complete: reasons.length === 0, reasons: [...new Set(reasons)] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------- signing
|
|
213
|
+
|
|
214
|
+
function issuanceFor(publicKeyPem) {
|
|
215
|
+
const fp = keyFingerprint(publicKeyPem);
|
|
216
|
+
return {
|
|
217
|
+
issuer: { id: `local-install:${fp.slice(0, 16)}`, kind: 'local-install', keyFingerprint: fp },
|
|
218
|
+
trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Sign a sealed chain. Only the signer domain may read the signing key; any other domain is refused. */
|
|
223
|
+
export function signReceiptChain(chain, { domain, privateKeyPem }) {
|
|
224
|
+
assertDomain(domain, RESOURCES.SIGNING_KEY, 'read', 'signer');
|
|
225
|
+
const intact = verifyChainIntegrity(chain);
|
|
226
|
+
if (!intact.ok) throw Object.assign(new Error(`refusing to sign a chain that is not intact: ${intact.reason}`), { code: 'chain-not-intact' });
|
|
227
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
228
|
+
const body = { schema: RECEIPT_ENVELOPE_SCHEMA, chain, issuance: issuanceFor(publicKeyPem) };
|
|
229
|
+
const sig = crypto.sign(null, Buffer.from(canonicalJson(body), 'utf8'), privateKeyPem);
|
|
230
|
+
return { ...body, signature: { algorithm: 'ed25519', canonicalisation: RECEIPT_ENVELOPE_SCHEMA, value: sig.toString('base64') } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ENVELOPE_KEYS = new Set(['schema', 'chain', 'issuance', 'signature']);
|
|
234
|
+
const ENFORCEABLE = ['filesystem-read', 'filesystem-write', 'command', 'network'];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Verify an envelope with the PUBLIC key alone.
|
|
238
|
+
*
|
|
239
|
+
* @param {object} envelope
|
|
240
|
+
* @param {string} publicKeyPem
|
|
241
|
+
* @param {object} [expected]
|
|
242
|
+
* @param {{taskId:string, policyVersion:number, digest:string}} [expected.binding] the CURRENT policy; a chain for any other digest or version is `superseded`
|
|
243
|
+
* @param {string[]} [expected.requiredDecisionIds] decision ids the run is known to have produced
|
|
244
|
+
* @returns `ok` means the signature and chain are intact; `fullyEnforced` is the stricter label.
|
|
245
|
+
*/
|
|
246
|
+
export function verifyReceiptEnvelope(envelope, publicKeyPem, expected = {}) {
|
|
247
|
+
const base = {
|
|
248
|
+
ok: false, signatureValid: false, chainIntact: false, complete: false, current: null, fullyEnforced: false,
|
|
249
|
+
label: 'invalid', reasons: [], breakAt: null, trustBasis: null, independentlyCertified: false, report: null,
|
|
250
|
+
};
|
|
251
|
+
const out = (patch) => Object.freeze({ ...base, ...patch });
|
|
252
|
+
if (!envelope || typeof envelope !== 'object' || envelope.schema !== RECEIPT_ENVELOPE_SCHEMA) return out({ reasons: ['not-an-envelope'] });
|
|
253
|
+
const extra = Object.keys(envelope).filter((k) => !ENVELOPE_KEYS.has(k));
|
|
254
|
+
if (extra.length) return out({ label: 'tampered', reasons: [`unsigned-fields:${extra.join(',')}`] });
|
|
255
|
+
if (!envelope.signature?.value || envelope.signature.algorithm !== 'ed25519') return out({ reasons: ['unsigned'] });
|
|
256
|
+
if (!publicKeyPem) return out({ reasons: ['no-public-key'] });
|
|
257
|
+
let sigOk = false;
|
|
258
|
+
try {
|
|
259
|
+
sigOk = crypto.verify(null, Buffer.from(canonicalJson({ schema: envelope.schema, chain: envelope.chain, issuance: envelope.issuance }), 'utf8'), publicKeyPem, Buffer.from(envelope.signature.value, 'base64'));
|
|
260
|
+
} catch { sigOk = false; }
|
|
261
|
+
if (!sigOk) return out({ label: 'tampered', reasons: ['signature-mismatch'] });
|
|
262
|
+
const iss = envelope.issuance;
|
|
263
|
+
if (!iss || !TRUST_BASES[iss.trustBasis] || iss.independentlyCertified !== false || iss.issuer?.keyFingerprint !== keyFingerprint(publicKeyPem)) {
|
|
264
|
+
return out({ signatureValid: true, label: 'invalid', reasons: ['issuer-invalid'] });
|
|
265
|
+
}
|
|
266
|
+
const intact = verifyChainIntegrity(envelope.chain);
|
|
267
|
+
if (!intact.ok) return out({ signatureValid: true, label: 'tampered', reasons: [intact.reason], breakAt: intact.breakAt, trustBasis: iss.trustBasis });
|
|
268
|
+
|
|
269
|
+
const chain = envelope.chain;
|
|
270
|
+
const comp = assessCompleteness(chain, { requiredDecisionIds: expected.requiredDecisionIds || [] });
|
|
271
|
+
let current = null;
|
|
272
|
+
const reasons = [...comp.reasons];
|
|
273
|
+
if (expected.binding) {
|
|
274
|
+
const eb = expected.binding;
|
|
275
|
+
current = eb.taskId === chain.binding.taskId && eb.policyVersion === chain.binding.policyVersion && eb.digest === chain.binding.manifestDigest;
|
|
276
|
+
if (!current) reasons.push('superseded-policy');
|
|
277
|
+
}
|
|
278
|
+
const start = chain.receipts[0];
|
|
279
|
+
const observed = start?.body?.observed ?? null;
|
|
280
|
+
const report = start?.body?.report ?? null;
|
|
281
|
+
const caps = (report?.capabilities || []).filter((c) => ENFORCEABLE.includes(c.kind));
|
|
282
|
+
const decisionsEnforced = chain.receipts.filter((r) => r.kind === 'decision' && ['runner', 'proxy'].includes(r.body.record.mediation)).every((r) => r.body.record.enforced === true);
|
|
283
|
+
const fullyEnforcedAll = comp.complete && current !== false && !!observed && observed.level === 'enforced' && observed.enforced === true
|
|
284
|
+
&& caps.length === ENFORCEABLE.length && caps.every((c) => c.enforced === true) && decisionsEnforced;
|
|
285
|
+
|
|
286
|
+
let label;
|
|
287
|
+
if (!comp.complete) label = 'incomplete-audit-trail';
|
|
288
|
+
else if (current === false) label = 'superseded-policy';
|
|
289
|
+
else if (!observed) label = 'not-run';
|
|
290
|
+
else if (fullyEnforcedAll) label = 'fully-enforced';
|
|
291
|
+
else if (observed.level === 'host-proved') label = 'host-proved-not-enforced';
|
|
292
|
+
else label = 'not-enforced';
|
|
293
|
+
|
|
294
|
+
return out({
|
|
295
|
+
ok: true, signatureValid: true, chainIntact: true, complete: comp.complete, current, fullyEnforced: label === 'fully-enforced',
|
|
296
|
+
label, reasons, trustBasis: iss.trustBasis, independentlyCertified: false, issuer: iss.issuer.id,
|
|
297
|
+
report: receiptReport(chain),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------- reporting
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* requested / checked / enforced for each capability, read from the SIGNED start
|
|
305
|
+
* receipt (what the runner observed), with the platform and network-boundary
|
|
306
|
+
* limitations that apply. Nothing here is recomputed from the current host.
|
|
307
|
+
*/
|
|
308
|
+
export function receiptReport(chain) {
|
|
309
|
+
const start = chain?.receipts?.[0];
|
|
310
|
+
const rep = start?.body?.report ?? null;
|
|
311
|
+
const observed = start?.body?.observed ?? null;
|
|
312
|
+
return Object.freeze({
|
|
313
|
+
level: observed?.level ?? 'none', backend: observed?.backend ?? null, probeDigest: observed?.probeDigest ?? null,
|
|
314
|
+
requested: start?.body?.requested ?? null,
|
|
315
|
+
capabilities: (rep?.capabilities || []).map((c) => ({
|
|
316
|
+
kind: c.kind, requested: c.requested, checked: c.checked, enforcedBy: c.enforcedBy, enforced: c.enforced === true,
|
|
317
|
+
...(c.payloadFiltering ? { payloadFiltering: c.payloadFiltering } : {}),
|
|
318
|
+
})),
|
|
319
|
+
resources: rep?.resources ?? null,
|
|
320
|
+
platforms: rep?.platforms ?? platformStatements(),
|
|
321
|
+
networkBoundary: rep ? (rep.capabilities || []).find((c) => c.kind === 'network')?.enforcedBy ?? null : null,
|
|
322
|
+
limitations: rep?.limitations ?? [],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------- storage
|
|
327
|
+
|
|
328
|
+
/** Is a directory safe to hold receipts for this task: it must overlap none of the task's declared roots. */
|
|
329
|
+
export function receiptDirectoryConflict(bound, dir) {
|
|
330
|
+
const real = canonicalPath(dir) ?? dir;
|
|
331
|
+
for (const r of [...bound.manifest.filesystem.read, ...bound.manifest.filesystem.write]) {
|
|
332
|
+
if (overlaps(real, canonicalPath(r) ?? r)) return r;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Write a signed envelope into a verifier-owned directory. Exclusive create: an
|
|
339
|
+
* existing receipt is never overwritten, so replacing one means deleting it, which
|
|
340
|
+
* is itself visible to anyone holding the previous head.
|
|
341
|
+
*/
|
|
342
|
+
export function writeReceiptEnvelope(dir, envelope, { domain } = {}) {
|
|
343
|
+
assertDomain(domain, RESOURCES.AUTHORITATIVE_EVIDENCE, 'write', 'writer');
|
|
344
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
345
|
+
const c = envelope.chain;
|
|
346
|
+
const safe = String(c.binding.taskId).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 60);
|
|
347
|
+
const file = path.join(dir, `${safe}.v${c.binding.policyVersion}.${c.head.slice(0, 12)}.receipt.json`);
|
|
348
|
+
fs.writeFileSync(file, `${JSON.stringify(envelope, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
349
|
+
return file;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function readReceiptEnvelope(file) {
|
|
353
|
+
const st = fs.statSync(file);
|
|
354
|
+
if (!st.isFile() || st.size > MAX_ENVELOPE_BYTES) throw Object.assign(new Error('receipt file is not a regular file within the size limit'), { code: 'bad-receipt-file' });
|
|
355
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
356
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Capability decisions as CORE-002 records, and the advisory (hook) view.
|
|
2
|
+
//
|
|
3
|
+
// A policy decision (decide.js) becomes an `agentic-security/capability-decision`
|
|
4
|
+
// record. The record schema already carries the honesty rule this module relies
|
|
5
|
+
// on: only runner or proxy mediation, naming a backend and the digest of an
|
|
6
|
+
// active probe, may claim `enforced`. `enforced` here is true only when the
|
|
7
|
+
// runner says the backend is an advertised one and every required control was
|
|
8
|
+
// proved; a development-host run is recorded as a runner-mediated decision with
|
|
9
|
+
// `enforced: false`.
|
|
10
|
+
import { SCHEMA_VERSION } from '../posture/assurance/schema-kit.js';
|
|
11
|
+
import { capabilityDecisionId, validateCapabilityDecision } from '../posture/assurance/contracts.js';
|
|
12
|
+
import { sanitizeSubject } from './reasons.js';
|
|
13
|
+
import { decide } from './decide.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} d a decision from `decide`
|
|
17
|
+
* @param {object} o
|
|
18
|
+
* @param {'runner'|'proxy'|'in-process-policy'|'hook-advisory'|'none'} o.mediation
|
|
19
|
+
* @param {boolean} o.enforced
|
|
20
|
+
* @param {string|null} o.backend
|
|
21
|
+
* @param {string|null} o.probeDigest
|
|
22
|
+
* @returns {{ok: boolean, record: object|null, errors: object[]}}
|
|
23
|
+
*/
|
|
24
|
+
export function toCapabilityDecisionRecord(d, { mediation, enforced, backend = null, probeDigest = null }) {
|
|
25
|
+
const rec = {
|
|
26
|
+
schema: 'agentic-security/capability-decision', schemaVersion: SCHEMA_VERSION,
|
|
27
|
+
taskId: d.taskId ?? 'unbound', capability: d.kind, subject: sanitizeSubject(d.subject, 200),
|
|
28
|
+
decision: d.decision, mediation, enforced: enforced === true, backend,
|
|
29
|
+
...(probeDigest ? { probeDigest } : {}),
|
|
30
|
+
reason: `${d.code}: ${d.reason}`,
|
|
31
|
+
};
|
|
32
|
+
rec.id = capabilityDecisionId(rec);
|
|
33
|
+
const v = validateCapabilityDecision(rec);
|
|
34
|
+
return v.ok ? { ok: true, record: Object.freeze(rec), errors: [] } : { ok: false, record: null, errors: v.errors };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a hook may say about an action. The same policy answer, labelled as
|
|
39
|
+
* advice: mediation `hook-advisory` and never enforced, however the manifest
|
|
40
|
+
* reads. A shell command string is not an action the policy can parse, so it is
|
|
41
|
+
* reported as unsupported for enforcement and left to the runner's structured
|
|
42
|
+
* execution.
|
|
43
|
+
*/
|
|
44
|
+
export function advise(bound, action, ctx) {
|
|
45
|
+
if (action && action.kind === 'command' && typeof action.command === 'string') {
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
advisory: true, enforced: false, decision: 'unsupported', code: 'interpreter-blocked',
|
|
48
|
+
note: 'a shell command string cannot be mediated; run it as an executable and an argument array through the capability runner',
|
|
49
|
+
record: null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const d = decide(bound, action, ctx);
|
|
53
|
+
const r = toCapabilityDecisionRecord(d, { mediation: 'hook-advisory', enforced: false, backend: null });
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
advisory: true, enforced: false, decision: d.decision, code: d.code,
|
|
56
|
+
note: 'advice only: a hook response explains a decision, it does not enforce one', record: r.ok ? r.record : null,
|
|
57
|
+
});
|
|
58
|
+
}
|