@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,367 @@
|
|
|
1
|
+
// Explicit policy recovery (X-506).
|
|
2
|
+
//
|
|
3
|
+
// A denied task does not talk its way out of a denial and does not retry until
|
|
4
|
+
// something gives. The only way a denial turns into a permission is a policy
|
|
5
|
+
// change that
|
|
6
|
+
//
|
|
7
|
+
// is OPERATOR-ONLY the grant is an Ed25519 signature over the exact change;
|
|
8
|
+
// the signing key lives in the signer domain, which a worker
|
|
9
|
+
// cannot read (sandbox/trust-domains.js, the protected key
|
|
10
|
+
// directory), so a worker cannot mint one and a forged
|
|
11
|
+
// signature fails verification
|
|
12
|
+
// is BOUND the signature covers the digest of the manifest it
|
|
13
|
+
// changes AND the digest of the manifest it produces, the
|
|
14
|
+
// task and both policy versions, so it is not a blank
|
|
15
|
+
// cheque and cannot be replayed on another task or version
|
|
16
|
+
// is BOUNDED a short validity window, a single use (nonce), and a
|
|
17
|
+
// finite number of changes per task
|
|
18
|
+
// is RECORDED every applied change is an entry in a hash-chained ledger
|
|
19
|
+
// creates a NEW VERSION the policy version rises by one. Every earlier binding
|
|
20
|
+
// is then stale (`binding-mismatch`) and every receipt for
|
|
21
|
+
// the earlier digest is superseded (receipts.js)
|
|
22
|
+
//
|
|
23
|
+
// A denial itself returns `blocked`, the missing capability and a proposed
|
|
24
|
+
// manifest change for a human to review (`proposeChange`). The proposal is data:
|
|
25
|
+
// applying it needs the operator grant above. Repeated denials of one action hit
|
|
26
|
+
// a finite limit and stay blocked (`createDenialGuard`); no path in this module
|
|
27
|
+
// turns a denial into an allow, falls back to unrestricted execution or loops.
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import { canonicalJson, keyFingerprint, TRUST_BASES } from '../posture/evidence-bundle.js';
|
|
30
|
+
import { mayDo, RESOURCES } from '../sandbox/trust-domains.js';
|
|
31
|
+
import { digestOf } from '../posture/assurance/identity.js';
|
|
32
|
+
import { bindManifest, validateManifest } from './manifest.js';
|
|
33
|
+
import { decide } from './decide.js';
|
|
34
|
+
import { detectSecretShapes } from './secrets.js';
|
|
35
|
+
import { normalizeHost, classifyAddress } from './address.js';
|
|
36
|
+
import { reasonText, sanitizeSubject } from './reasons.js';
|
|
37
|
+
import { requiredControlsFor, unmetControls, probeCapabilityControls } from './probes.js';
|
|
38
|
+
import { toolCapabilityFor } from './tool-registry.js';
|
|
39
|
+
|
|
40
|
+
export const GRANT_SCHEMA = 'agentic-security/policy-grant';
|
|
41
|
+
export const LEDGER_SCHEMA = 'agentic-security/policy-ledger';
|
|
42
|
+
export const DEFAULT_RETRY_LIMIT = 3;
|
|
43
|
+
export const MAX_RETRY_LIMIT = 10;
|
|
44
|
+
export const DEFAULT_TASK_DENIAL_BUDGET = 20;
|
|
45
|
+
export const MAX_TASK_DENIAL_BUDGET = 100;
|
|
46
|
+
export const MAX_GRANT_TTL_MS = 60 * 60 * 1000;
|
|
47
|
+
export const DEFAULT_MAX_CHANGES = 5;
|
|
48
|
+
const MAX_TRACKED = 1000;
|
|
49
|
+
const GENESIS = '0'.repeat(64);
|
|
50
|
+
|
|
51
|
+
const sha = (v) => crypto.createHash('sha256').update(typeof v === 'string' ? v : canonicalJson(v)).digest('hex');
|
|
52
|
+
const clean = (s) => typeof s === 'string' && !/[\u0000-\u001f\u007f]/.test(s) && !detectSecretShapes(s);
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- proposals
|
|
55
|
+
|
|
56
|
+
// Codes that name a structural refusal. A proposal for any of these would be an
|
|
57
|
+
// invitation to widen the boundary around keys, labels, links and secrets.
|
|
58
|
+
const NOT_PROPOSABLE = new Set([
|
|
59
|
+
'binding-mismatch', 'unknown-action', 'invalid-manifest', 'scope-expansion', 'path-invalid', 'path-traversal',
|
|
60
|
+
'symlink-escape', 'protected-path', 'executable-not-absolute', 'executable-unresolvable', 'executable-in-writable-root',
|
|
61
|
+
'args-invalid', 'secret-in-argument', 'host-invalid', 'port-invalid', 'dns-private-address', 'dns-changed',
|
|
62
|
+
'payload-too-large', 'payload-uninspectable', 'resource-limit-exceeded', 'retry-limit', 'task-halted', 'identity-spoofed',
|
|
63
|
+
'identity-missing', 'tool-unclassified', 'allowed', 'delegation-depth',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function unproposable(decision, why) {
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
proposable: false, why, missing: Object.freeze({ capability: decision.kind, code: decision.code, detail: decision.subject }),
|
|
69
|
+
selfGrantable: false, requiresOperatorGrant: true, change: null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The missing capability and a reviewable manifest change for one denial. The
|
|
75
|
+
* change is the NARROWEST addition that would allow exactly this action (an exact
|
|
76
|
+
* path, an exact argument array, one host and port). It is a proposal: a worker
|
|
77
|
+
* can read it and cannot apply it.
|
|
78
|
+
*/
|
|
79
|
+
export function proposeChange(bound, action, decision) {
|
|
80
|
+
const base = { kind: decision?.kind ?? 'unknown', code: decision?.code ?? 'unknown-action', subject: decision?.subject ?? '' };
|
|
81
|
+
if (!decision || decision.decision === 'allow') return unproposable({ ...base, code: 'allowed' }, 'the action was allowed; there is nothing to propose');
|
|
82
|
+
if (NOT_PROPOSABLE.has(decision.code)) return unproposable(decision, `${reasonText(decision.code)}; no manifest change is proposed for this refusal`);
|
|
83
|
+
let add = null; let risk = 'scope-addition';
|
|
84
|
+
const a = action || {};
|
|
85
|
+
switch (a.kind) {
|
|
86
|
+
case 'filesystem-read':
|
|
87
|
+
case 'filesystem-write':
|
|
88
|
+
if (typeof a.path === 'string' && clean(a.path)) add = { filesystem: { [a.kind === 'filesystem-write' ? 'write' : 'read']: [a.path] } };
|
|
89
|
+
break;
|
|
90
|
+
case 'command':
|
|
91
|
+
if (typeof a.executable === 'string' && clean(a.executable) && (a.args ?? []).every((x) => typeof x === 'string' && clean(x))) {
|
|
92
|
+
const entry = { executable: a.executable, args: { mode: 'exact', values: [...(a.args ?? [])] } };
|
|
93
|
+
if (decision.code === 'interpreter-blocked' || decision.code === 'interpreter-args-unpinned') { entry.interpreter = 'scoped'; risk = 'interpreter'; }
|
|
94
|
+
add = { commands: [entry] };
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case 'network':
|
|
98
|
+
// A literal loopback, private, link-local or metadata address is never offered as a destination.
|
|
99
|
+
if (typeof a.host === 'string' && clean(a.host) && Number.isInteger(a.port) && (() => {
|
|
100
|
+
const h = normalizeHost(a.host);
|
|
101
|
+
return h.ok && (h.kind === 'name' || classifyAddress(h.host) === 'public');
|
|
102
|
+
})()) {
|
|
103
|
+
add = { network: [{ host: a.host, port: a.port, schemes: [a.scheme === 'http' ? 'http' : 'https'] }] };
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case 'tool':
|
|
107
|
+
// Only a tool this build knows can be proposed; an unknown name is not a capability.
|
|
108
|
+
if (typeof a.tool === 'string' && toolCapabilityFor(a.tool)) add = { tools: [a.tool] };
|
|
109
|
+
break;
|
|
110
|
+
case 'delegation':
|
|
111
|
+
if (decision.code === 'delegation-not-allowed') add = { delegation: { allow: true, maxDepth: 1 } };
|
|
112
|
+
break;
|
|
113
|
+
default: break;
|
|
114
|
+
}
|
|
115
|
+
if (!add) return unproposable(decision, 'the request cannot be turned into a safe manifest addition');
|
|
116
|
+
const change = Object.freeze({ add });
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
proposable: true, why: null, risk,
|
|
119
|
+
missing: Object.freeze({ capability: decision.kind, code: decision.code, detail: decision.subject }),
|
|
120
|
+
selfGrantable: false, requiresOperatorGrant: true,
|
|
121
|
+
nextPolicyVersion: bound.binding.policyVersion + 1,
|
|
122
|
+
change, changeDigest: digestOf(change),
|
|
123
|
+
review: `Add to task ${sanitizeSubject(bound.binding.taskId, 80)} at policy version ${bound.binding.policyVersion + 1}: ${sanitizeSubject(JSON.stringify(add), 300)}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- denial guard
|
|
128
|
+
|
|
129
|
+
function actionKey(binding, action) {
|
|
130
|
+
const a = action && typeof action === 'object' ? action : {};
|
|
131
|
+
const material = { kind: a.kind ?? null, path: a.path ?? null, executable: a.executable ?? null, args: Array.isArray(a.args) ? a.args : null, host: a.host ?? null, port: a.port ?? null, scheme: a.scheme ?? null, tool: a.tool ?? null };
|
|
132
|
+
return sha({ taskId: binding?.taskId ?? null, policyVersion: binding?.policyVersion ?? null, material });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bounded denial handling. `admit` is asked BEFORE the policy is evaluated; once
|
|
137
|
+
* an action has been denied `retryLimit` times in one policy version, or the task
|
|
138
|
+
* has been denied `taskBudget` times in all, the answer is a fixed refusal and the
|
|
139
|
+
* action is not even evaluated again. A new policy version starts a new count (the
|
|
140
|
+
* operator changed something, so a retry is no longer the same request).
|
|
141
|
+
*/
|
|
142
|
+
export function createDenialGuard({ retryLimit = DEFAULT_RETRY_LIMIT, taskBudget = DEFAULT_TASK_DENIAL_BUDGET } = {}) {
|
|
143
|
+
const rl = Number.isInteger(retryLimit) && retryLimit >= 1 ? Math.min(retryLimit, MAX_RETRY_LIMIT) : DEFAULT_RETRY_LIMIT;
|
|
144
|
+
const tb = Number.isInteger(taskBudget) && taskBudget >= 1 ? Math.min(taskBudget, MAX_TASK_DENIAL_BUDGET) : DEFAULT_TASK_DENIAL_BUDGET;
|
|
145
|
+
const perAction = new Map();
|
|
146
|
+
const perTask = new Map();
|
|
147
|
+
const taskKey = (b) => `${b?.taskId ?? 'unbound'}@${b?.policyVersion ?? 0}`;
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
retryLimit: rl, taskBudget: tb,
|
|
150
|
+
admit(binding, action) {
|
|
151
|
+
const t = perTask.get(taskKey(binding)) ?? 0;
|
|
152
|
+
if (t >= tb || perAction.size >= MAX_TRACKED) return { admit: false, code: 'task-halted' };
|
|
153
|
+
if ((perAction.get(actionKey(binding, action)) ?? 0) >= rl) return { admit: false, code: 'retry-limit' };
|
|
154
|
+
return { admit: true, code: null };
|
|
155
|
+
},
|
|
156
|
+
record(binding, action) {
|
|
157
|
+
const k = actionKey(binding, action);
|
|
158
|
+
const n = (perAction.get(k) ?? 0) + 1;
|
|
159
|
+
perAction.set(k, n);
|
|
160
|
+
perTask.set(taskKey(binding), (perTask.get(taskKey(binding)) ?? 0) + 1);
|
|
161
|
+
return { attempts: n, remaining: Math.max(0, rl - n), exhausted: n >= rl };
|
|
162
|
+
},
|
|
163
|
+
stats(binding) { return { taskDenials: perTask.get(taskKey(binding)) ?? 0, trackedActions: perAction.size }; },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One mediated decision with recovery semantics. The result is either the policy
|
|
169
|
+
* allowing the action, or `blocked`; there is no third outcome, no retry inside
|
|
170
|
+
* this function and no fallback.
|
|
171
|
+
*/
|
|
172
|
+
export function mediate(bound, action, ctx, { guard } = {}) {
|
|
173
|
+
const binding = ctx?.binding;
|
|
174
|
+
if (guard) {
|
|
175
|
+
const adm = guard.admit(binding, action);
|
|
176
|
+
if (!adm.admit) {
|
|
177
|
+
const decision = Object.freeze({
|
|
178
|
+
decision: 'deny', code: adm.code, reason: reasonText(adm.code), kind: typeof action?.kind === 'string' ? sanitizeSubject(action.kind, 40) : 'unknown',
|
|
179
|
+
subject: '(action not re-evaluated)', taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
|
|
180
|
+
});
|
|
181
|
+
return Object.freeze({ status: 'blocked', blocked: true, decision, ...proposalFields(unproposable(decision, reasonText(adm.code))), attemptsRemaining: 0, exhausted: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const decision = decide(bound, action, ctx);
|
|
185
|
+
if (decision.decision === 'allow') return Object.freeze({ status: 'ok', blocked: false, decision, proposal: null, missing: null, attemptsRemaining: null, exhausted: false });
|
|
186
|
+
const counted = guard ? guard.record(binding, action) : null;
|
|
187
|
+
const proposal = counted?.exhausted ? unproposable(decision, `${reasonText('retry-limit')}`) : proposeChange(bound, action, decision);
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
status: 'blocked', blocked: true, decision, ...proposalFields(proposal),
|
|
190
|
+
attemptsRemaining: counted ? counted.remaining : null, exhausted: counted ? counted.exhausted : false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function proposalFields(p) { return { proposal: p, missing: p.missing }; }
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------- operator grants
|
|
197
|
+
|
|
198
|
+
function assertSigner(domain) {
|
|
199
|
+
if (!mayDo(domain, RESOURCES.SIGNING_KEY, 'read')) {
|
|
200
|
+
throw Object.assign(new Error(`the ${String(domain)} domain may not sign a policy grant`), { code: 'domain-denied' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ADD_KEYS = ['filesystem', 'commands', 'network', 'tools', 'delegation'];
|
|
205
|
+
|
|
206
|
+
/** Apply an add-only change to a normalized manifest; the result is validated and bound at the next policy version. */
|
|
207
|
+
export function applyChangeToManifest(manifest, change) {
|
|
208
|
+
if (!change || typeof change !== 'object' || Array.isArray(change) || Object.keys(change).some((k) => k !== 'add')) return { ok: false, code: 'change-invalid', bound: null };
|
|
209
|
+
const add = change.add;
|
|
210
|
+
if (!add || typeof add !== 'object' || Array.isArray(add) || Object.keys(add).length === 0 || Object.keys(add).some((k) => !ADD_KEYS.includes(k))) return { ok: false, code: 'change-invalid', bound: null };
|
|
211
|
+
const next = JSON.parse(JSON.stringify(manifest));
|
|
212
|
+
next.policyVersion = manifest.policyVersion + 1;
|
|
213
|
+
if (add.filesystem) {
|
|
214
|
+
if (typeof add.filesystem !== 'object' || Object.keys(add.filesystem).some((k) => !['read', 'write'].includes(k))) return { ok: false, code: 'change-invalid', bound: null };
|
|
215
|
+
next.filesystem.read = [...next.filesystem.read, ...(add.filesystem.read ?? [])];
|
|
216
|
+
next.filesystem.write = [...next.filesystem.write, ...(add.filesystem.write ?? [])];
|
|
217
|
+
}
|
|
218
|
+
if (add.commands) next.commands = [...next.commands, ...add.commands];
|
|
219
|
+
if (add.network) next.network = [...next.network, ...add.network];
|
|
220
|
+
if (add.tools) next.tools = [...next.tools, ...add.tools];
|
|
221
|
+
if (add.delegation) next.delegation = add.delegation;
|
|
222
|
+
const b = bindManifest(next);
|
|
223
|
+
return b.ok ? { ok: true, code: null, bound: b.bound } : { ok: false, code: 'change-invalid', bound: null, errors: b.errors };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Operator side. Signs a grant for ONE change to ONE manifest. Refuses unless the
|
|
228
|
+
* caller declares the signer domain; a worker or target cannot sign (and in any
|
|
229
|
+
* case does not hold the key).
|
|
230
|
+
*/
|
|
231
|
+
export function signPolicyGrant({ domain, privateKeyPem, bound, change, operator, reason, now = new Date(), ttlMs = 15 * 60 * 1000, nonce }) {
|
|
232
|
+
assertSigner(domain);
|
|
233
|
+
const applied = applyChangeToManifest(bound.manifest, change);
|
|
234
|
+
if (!applied.ok) throw Object.assign(new Error('the change is not a valid add-only manifest change'), { code: applied.code });
|
|
235
|
+
const ttl = Math.min(Math.max(1, ttlMs), MAX_GRANT_TTL_MS);
|
|
236
|
+
const payload = {
|
|
237
|
+
schema: GRANT_SCHEMA, taskId: bound.binding.taskId,
|
|
238
|
+
fromPolicyVersion: bound.binding.policyVersion, fromDigest: bound.binding.digest,
|
|
239
|
+
toPolicyVersion: applied.bound.binding.policyVersion, toDigest: applied.bound.binding.digest,
|
|
240
|
+
operator: sanitizeSubject(operator, 80), reason: sanitizeSubject(reason, 200),
|
|
241
|
+
issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + ttl).toISOString(),
|
|
242
|
+
nonce: nonce ?? crypto.randomBytes(12).toString('hex'),
|
|
243
|
+
};
|
|
244
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
245
|
+
const issuance = {
|
|
246
|
+
issuer: { id: `local-install:${keyFingerprint(publicKeyPem).slice(0, 16)}`, kind: 'local-install', keyFingerprint: keyFingerprint(publicKeyPem) },
|
|
247
|
+
trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
248
|
+
};
|
|
249
|
+
const sig = crypto.sign(null, Buffer.from(canonicalJson({ payload, issuance }), 'utf8'), privateKeyPem);
|
|
250
|
+
return Object.freeze({ payload, issuance, signature: { algorithm: 'ed25519', value: sig.toString('base64') } });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function verifyGrant(grant, publicKeyPem) {
|
|
254
|
+
if (!grant || typeof grant !== 'object' || !grant.payload || !grant.signature || grant.payload.schema !== GRANT_SCHEMA) return { ok: false, code: 'grant-invalid' };
|
|
255
|
+
if (Object.keys(grant).some((k) => !['payload', 'issuance', 'signature'].includes(k))) return { ok: false, code: 'grant-invalid' };
|
|
256
|
+
if (grant.signature.algorithm !== 'ed25519' || typeof grant.signature.value !== 'string') return { ok: false, code: 'grant-invalid' };
|
|
257
|
+
if (!publicKeyPem) return { ok: false, code: 'grant-unsigned' };
|
|
258
|
+
let ok = false;
|
|
259
|
+
try { ok = crypto.verify(null, Buffer.from(canonicalJson({ payload: grant.payload, issuance: grant.issuance }), 'utf8'), publicKeyPem, Buffer.from(grant.signature.value, 'base64')); } catch { ok = false; }
|
|
260
|
+
if (!ok) return { ok: false, code: 'grant-signature-invalid' };
|
|
261
|
+
const iss = grant.issuance;
|
|
262
|
+
if (!iss || !TRUST_BASES[iss.trustBasis] || iss.independentlyCertified !== false || iss.issuer?.keyFingerprint !== keyFingerprint(publicKeyPem)) return { ok: false, code: 'grant-issuer-invalid' };
|
|
263
|
+
return { ok: true, code: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------- ledger
|
|
267
|
+
|
|
268
|
+
/** A hash-chained record of every applied policy change. Held by the controller, never by a worker. */
|
|
269
|
+
export function createPolicyLedger({ maxChanges = DEFAULT_MAX_CHANGES } = {}) {
|
|
270
|
+
const max = Number.isInteger(maxChanges) && maxChanges >= 1 ? Math.min(maxChanges, 50) : DEFAULT_MAX_CHANGES;
|
|
271
|
+
const entries = [];
|
|
272
|
+
const nonces = new Set();
|
|
273
|
+
const current = new Map(); // taskId -> {policyVersion, digest}
|
|
274
|
+
const head = () => (entries.length ? entries[entries.length - 1].hash : GENESIS);
|
|
275
|
+
return {
|
|
276
|
+
maxChanges: max,
|
|
277
|
+
entries: () => entries.map((e) => ({ ...e })),
|
|
278
|
+
changesFor: (taskId) => entries.filter((e) => e.taskId === taskId).length,
|
|
279
|
+
nonceUsed: (n) => nonces.has(n),
|
|
280
|
+
/** Seed the current policy of a task (the first bound manifest). */
|
|
281
|
+
register(bound) { if (!current.has(bound.binding.taskId)) current.set(bound.binding.taskId, { policyVersion: bound.binding.policyVersion, digest: bound.binding.digest }); },
|
|
282
|
+
current: (taskId) => current.get(taskId) ?? null,
|
|
283
|
+
isCurrent: (binding) => { const c = current.get(binding?.taskId); return !!c && c.policyVersion === binding.policyVersion && c.digest === binding.digest; },
|
|
284
|
+
append(entry) {
|
|
285
|
+
const body = { seq: entries.length, prev: head(), ...entry };
|
|
286
|
+
const full = { ...body, hash: sha(body) };
|
|
287
|
+
entries.push(Object.freeze(full));
|
|
288
|
+
nonces.add(entry.nonce);
|
|
289
|
+
current.set(entry.taskId, { policyVersion: entry.toPolicyVersion, digest: entry.toDigest });
|
|
290
|
+
return full;
|
|
291
|
+
},
|
|
292
|
+
verify() { return verifyLedgerEntries(entries); },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function verifyLedgerEntries(list) {
|
|
297
|
+
let prev = GENESIS;
|
|
298
|
+
for (let i = 0; i < list.length; i++) {
|
|
299
|
+
const { hash, ...body } = list[i];
|
|
300
|
+
if (body.seq !== i || body.prev !== prev || sha(body) !== hash) return { ok: false, breakAt: i };
|
|
301
|
+
prev = hash;
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, breakAt: null, head: prev };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------- applying a grant
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Apply an operator grant to a task's policy. Returns the NEW bound manifest (a
|
|
310
|
+
* new policy version), the ledger entry and the re-run preflight. On any refusal
|
|
311
|
+
* `ok` is false, `bound` is null and the old policy is untouched.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} o
|
|
314
|
+
* @param {{manifest:object,binding:object}} o.bound the current policy
|
|
315
|
+
* @param {object} o.change the proposed `{add}`
|
|
316
|
+
* @param {object} o.grant from `signPolicyGrant`
|
|
317
|
+
* @param {string} o.publicKeyPem the operator's public key
|
|
318
|
+
* @param {object} o.ledger from `createPolicyLedger`
|
|
319
|
+
* @param {Date} [o.now]
|
|
320
|
+
* @param {object} [o.deniedAction] re-decided under the new policy
|
|
321
|
+
* @param {object} [o.probeReport] injected probe report (default: probe this host)
|
|
322
|
+
*/
|
|
323
|
+
export async function applyPolicyChange({ bound, change, grant, publicKeyPem, ledger, now = new Date(), deniedAction, probeReport, ctxExtra = {} }) {
|
|
324
|
+
const refuse = (code) => ({ ok: false, code, reason: `policy change refused: ${code}`, bound: null, entry: null, preflight: null });
|
|
325
|
+
if (!bound?.manifest || !bound?.binding || !ledger) return refuse('invalid-input');
|
|
326
|
+
const v = verifyGrant(grant, publicKeyPem);
|
|
327
|
+
if (!v.ok) return refuse(v.code);
|
|
328
|
+
const p = grant.payload;
|
|
329
|
+
const issued = Date.parse(p.issuedAt); const expires = Date.parse(p.expiresAt);
|
|
330
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires - issued > MAX_GRANT_TTL_MS || expires <= issued) return refuse('grant-window-invalid');
|
|
331
|
+
if (now.getTime() < issued) return refuse('grant-not-yet-valid');
|
|
332
|
+
if (now.getTime() >= expires) return refuse('grant-expired');
|
|
333
|
+
const b = bound.binding;
|
|
334
|
+
if (p.taskId !== b.taskId) return refuse('grant-wrong-task');
|
|
335
|
+
if (p.fromPolicyVersion !== b.policyVersion || p.fromDigest !== b.digest) return refuse('grant-stale');
|
|
336
|
+
ledger.register(bound);
|
|
337
|
+
if (!ledger.isCurrent(b)) return refuse('grant-stale');
|
|
338
|
+
if (ledger.nonceUsed(p.nonce)) return refuse('grant-replayed');
|
|
339
|
+
if (ledger.changesFor(b.taskId) >= ledger.maxChanges) return refuse('change-limit');
|
|
340
|
+
const applied = applyChangeToManifest(bound.manifest, change);
|
|
341
|
+
if (!applied.ok) return refuse(applied.code);
|
|
342
|
+
if (applied.bound.binding.digest !== p.toDigest || applied.bound.binding.policyVersion !== p.toPolicyVersion) return refuse('grant-change-mismatch');
|
|
343
|
+
|
|
344
|
+
const entry = ledger.append({
|
|
345
|
+
taskId: b.taskId, operator: p.operator, reason: p.reason, nonce: p.nonce,
|
|
346
|
+
fromPolicyVersion: b.policyVersion, fromDigest: b.digest, toPolicyVersion: p.toPolicyVersion, toDigest: p.toDigest,
|
|
347
|
+
changeDigest: digestOf(change), at: now.toISOString(),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Rerun what the change can affect: the manifest itself, the controls the new
|
|
351
|
+
// manifest depends on, and the action that was denied.
|
|
352
|
+
const nb = applied.bound;
|
|
353
|
+
const pr = probeReport || await probeCapabilityControls({});
|
|
354
|
+
const required = requiredControlsFor(nb.manifest);
|
|
355
|
+
const unmet = unmetControls(pr, required);
|
|
356
|
+
const valid = validateManifest(nb.manifest).ok;
|
|
357
|
+
const redecision = deniedAction ? decide(nb, deniedAction, { binding: nb.binding, ...ctxExtra }) : null;
|
|
358
|
+
const preflight = {
|
|
359
|
+
manifestValid: valid, requiredControls: required, unmet,
|
|
360
|
+
redecision: redecision ? { decision: redecision.decision, code: redecision.code } : null,
|
|
361
|
+
ready: valid && unmet.length === 0 && (!redecision || redecision.decision === 'allow'),
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
ok: true, code: null, reason: 'policy changed; a new policy version was created', bound: nb, entry, preflight,
|
|
365
|
+
supersedes: { policyVersion: b.policyVersion, digest: b.digest },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The capability report: requested, checked and enforced, side by side.
|
|
2
|
+
//
|
|
3
|
+
// Three different things get confused when a sandbox is described, so each is its
|
|
4
|
+
// own field for every capability:
|
|
5
|
+
//
|
|
6
|
+
// requested what the manifest asked for
|
|
7
|
+
// checked the state of each control that would enforce it, as established by
|
|
8
|
+
// the active probes on THIS host in THIS process
|
|
9
|
+
// enforced true only when the platform's backend is an advertised one AND
|
|
10
|
+
// every control the manifest depends on was proved. A host that
|
|
11
|
+
// proves the controls but is not an advertised backend reports
|
|
12
|
+
// `level: 'host-proved'` and `enforced: false` for everything.
|
|
13
|
+
//
|
|
14
|
+
// A capability that only an in-process check covers (tools, delegation) says so
|
|
15
|
+
// and is never `enforced`; that check explains a decision, it does not stop the
|
|
16
|
+
// task. Process-count caps are carried and reported `unverified`, on every
|
|
17
|
+
// backend.
|
|
18
|
+
import { platformStatements } from './probes.js';
|
|
19
|
+
|
|
20
|
+
const FS_READ = ['fs-read-confinement', 'read-denial'];
|
|
21
|
+
const FS_WRITE = ['write-confinement', 'fs-multi-root-write'];
|
|
22
|
+
const COMMAND = ['tree-termination', 'env-scrub'];
|
|
23
|
+
const NETWORK = ['network', 'network-mediation'];
|
|
24
|
+
|
|
25
|
+
export const REPORT_LIMITATIONS = Object.freeze([
|
|
26
|
+
'Top-level executables and arguments are mediated. A descendant inherits file and network confinement and is terminated with the task, but its own exec calls are not allowlisted.',
|
|
27
|
+
'Metadata of the ancestors of a declared root is visible; the existence and size of other paths are not.',
|
|
28
|
+
'HTTPS is an opaque tunnel to a declared destination: its payload is not inspected. Plaintext HTTP is filtered for secrets before it is forwarded.',
|
|
29
|
+
'The mediation proxy accepts connections from any local process. It only forwards to declared destinations, so the exposure is the declared set.',
|
|
30
|
+
'A process that double-forks and calls setsid between two supervisor sweeps can outlive the task; only a PID namespace or cgroup closes that gap.',
|
|
31
|
+
'Process-count caps are per-user and system-wide on the host and did not refuse on a hosted Linux runner in a previous release; none is claimed enforced on any backend.',
|
|
32
|
+
'Address-space (memory) caps are not enforceable on macOS and are carried, not enforced.',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function states(controls, names) {
|
|
36
|
+
return Object.fromEntries(names.map((n) => [n, controls?.[n]?.state ?? 'not-probed']));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} o
|
|
41
|
+
* @param {{manifest: object}} o.bound
|
|
42
|
+
* @param {{platform:string, backend:string, controls:object, probeDigest:string}} o.probeReport
|
|
43
|
+
* @param {'enforced'|'host-proved'|'none'} o.level
|
|
44
|
+
* @param {string[]} o.required controls the manifest depends on
|
|
45
|
+
*/
|
|
46
|
+
export function buildCapabilityReport({ bound, probeReport, level, required }) {
|
|
47
|
+
const m = bound.manifest;
|
|
48
|
+
const c = probeReport?.controls || {};
|
|
49
|
+
const allProved = required.every((n) => c[n]?.state === 'proved');
|
|
50
|
+
const enforcedOn = (names) => level === 'enforced' && names.every((n) => c[n]?.state === 'proved') && allProved;
|
|
51
|
+
const fileLimitProved = c['file-size-limit']?.state === 'proved';
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
platform: probeReport?.platform ?? process.platform,
|
|
55
|
+
backend: probeReport?.backend ?? 'disabled',
|
|
56
|
+
level,
|
|
57
|
+
probeDigest: probeReport?.probeDigest ?? null,
|
|
58
|
+
platforms: platformStatements(),
|
|
59
|
+
controls: Object.fromEntries(Object.entries(c).map(([k, v]) => [k, { state: v.state, ...(v.reason ? { reason: v.reason } : {}) }])),
|
|
60
|
+
capabilities: [
|
|
61
|
+
{ kind: 'filesystem-read', requested: { roots: m.filesystem.read.length + m.filesystem.write.length }, checked: states(c, FS_READ), enforcedBy: 'runner', enforced: enforcedOn(FS_READ) },
|
|
62
|
+
{ kind: 'filesystem-write', requested: { roots: m.filesystem.write.length }, checked: states(c, FS_WRITE), enforcedBy: 'runner', enforced: enforcedOn(FS_WRITE) },
|
|
63
|
+
{ kind: 'command', requested: { commands: m.commands.length }, checked: states(c, COMMAND), enforcedBy: 'runner', enforced: enforcedOn(COMMAND), descendantExec: 'not-allowlisted' },
|
|
64
|
+
{
|
|
65
|
+
kind: 'network', requested: { destinations: m.network.length }, checked: states(c, m.network.length ? NETWORK : ['network']),
|
|
66
|
+
enforcedBy: m.network.length ? 'proxy' : 'runner', enforced: enforcedOn(m.network.length ? NETWORK : ['network']),
|
|
67
|
+
payloadFiltering: 'plaintext-http-only',
|
|
68
|
+
},
|
|
69
|
+
{ kind: 'tool', requested: { tools: m.tools.length }, checked: {}, enforcedBy: 'in-process-policy', enforced: false },
|
|
70
|
+
{ kind: 'delegation', requested: { allow: m.delegation.allow, maxDepth: m.delegation.maxDepth }, checked: {}, enforcedBy: 'in-process-policy', enforced: false },
|
|
71
|
+
],
|
|
72
|
+
resources: {
|
|
73
|
+
timeoutMs: { requested: m.resources.timeoutMs ?? null, enforced: level === 'enforced', by: 'supervisor deadline with process-tree termination' },
|
|
74
|
+
maxOutputBytes: { requested: m.resources.maxOutputBytes ?? null, enforced: level === 'enforced', by: 'supervisor output cap with process-tree termination' },
|
|
75
|
+
maxFileSizeKb: { requested: m.resources.maxFileSizeKb ?? null, enforced: level === 'enforced' && fileLimitProved, state: c['file-size-limit']?.state ?? 'not-probed' },
|
|
76
|
+
maxProcesses: { requested: m.resources.maxProcesses ?? null, enforced: false, state: 'unverified' },
|
|
77
|
+
maxMemoryMiB: { requested: m.resources.maxMemoryMiB ?? null, enforced: false, state: 'not-enforced' },
|
|
78
|
+
},
|
|
79
|
+
limitations: [...REPORT_LIMITATIONS],
|
|
80
|
+
};
|
|
81
|
+
}
|