@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.
Files changed (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. 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
+ }