@clear-capabilities/agentic-security-scanner 0.162.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  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 +1 -1
  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 +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,256 @@
1
+ // Deterministic capability decisions (X-501.AC02, X-501.AC03).
2
+ //
3
+ // `decide(bound, action, ctx)` answers one question: does this task's bound
4
+ // manifest permit this one action? The answer is a frozen
5
+ // `{ decision, code, reason, kind, subject, taskId, manifestDigest }`:
6
+ //
7
+ // - deny-by-default: the answer is `allow` only when a specific declared grant
8
+ // covers the action; every other path, including every error, is `deny`
9
+ // - deterministic: no clock, no randomness, no environment; the same bound
10
+ // manifest, action and context give the same answer (the filesystem is read
11
+ // only to resolve symbolic links and to look at an executable)
12
+ // - sanitized: `code` comes from the closed table in reasons.js and `reason` is
13
+ // the fixed sentence beside it; the one request-derived field, `subject`, is
14
+ // redacted and length-capped
15
+ // - fail-closed: an unknown action kind, a binding that does not match, an
16
+ // argument list that is not plain strings, a path that cannot be resolved,
17
+ // an interpreter that is not declared as one: all `deny`
18
+ //
19
+ // This is a POLICY decision. Whether the operating system then actually stops the
20
+ // task from doing what the decision denied is the runner's job (runner.js), and
21
+ // only the runner may claim enforcement. A hook or an in-process check that calls
22
+ // this function explains; it does not enforce.
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { detectSecretShapes } from './secrets.js';
27
+ import { CAPABILITY_KINDS } from '../posture/assurance/contracts.js';
28
+ import { reasonText, sanitizeSubject } from './reasons.js';
29
+ import { lexicalPath, canonicalPath, isWithin } from './paths.js';
30
+ import { normalizeHost, classifyAddress } from './address.js';
31
+ import { deriveChild } from './manifest.js';
32
+
33
+ const MAX_ARGS = 256;
34
+ const MAX_ARG_LEN = 8192;
35
+
36
+ // Executables that run code supplied elsewhere (their arguments, a script file,
37
+ // standard input): shells, language runtimes, launchers and build drivers. A
38
+ // shell allowlist cannot see through any of them, so each is refused unless the
39
+ // manifest declares it as a scoped interpreter with its exact argument list.
40
+ const INTERPRETER_NAMES = new Set([
41
+ 'sh', 'bash', 'zsh', 'dash', 'ksh', 'csh', 'tcsh', 'fish', 'ash', 'busybox', 'env', 'xargs', 'nice', 'nohup',
42
+ 'time', 'timeout', 'sudo', 'doas', 'su', 'ssh', 'osascript', 'launchctl', 'script', 'expect', 'tclsh', 'wish',
43
+ 'awk', 'gawk', 'mawk', 'nawk', 'java', 'jshell', 'deno', 'bun', 'nodejs', 'swift', 'make', 'gmake',
44
+ 'npm', 'npx', 'yarn', 'pnpm', 'pip', 'pip3', 'pipx', 'gem', 'bundle', 'cargo', 'go', 'gradle', 'mvn', 'watch',
45
+ ]);
46
+ const INTERPRETER_FAMILY = /^(?:python|pypy|ruby|perl|php|lua|luajit|node|tclsh)[0-9.]*$/;
47
+
48
+ /**
49
+ * What kind of executable this is. `interpreter` is true for a known shell or
50
+ * runtime by name, and for any file that starts with a `#!` line (a script,
51
+ * whatever it is called). A name is a hint, the shebang is the file's own word.
52
+ */
53
+ export function classifyExecutable(realPath) {
54
+ const base = path.basename(realPath).toLowerCase();
55
+ if (INTERPRETER_NAMES.has(base) || INTERPRETER_FAMILY.test(base)) return { interpreter: true, via: 'name' };
56
+ let fd;
57
+ try {
58
+ fd = fs.openSync(realPath, 'r');
59
+ const buf = Buffer.alloc(2);
60
+ const n = fs.readSync(fd, buf, 0, 2, 0);
61
+ if (n === 2 && buf[0] === 0x23 && buf[1] === 0x21) return { interpreter: true, via: 'shebang' };
62
+ } catch { /* unreadable: handled by the caller's stat check */ } finally {
63
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } }
64
+ }
65
+ return { interpreter: false, via: null };
66
+ }
67
+
68
+ function result(bound, action, decision, code, subject) {
69
+ return Object.freeze({
70
+ decision, code, reason: reasonText(code), kind: typeof action?.kind === 'string' ? sanitizeSubject(action.kind, 40) : 'unknown',
71
+ subject: sanitizeSubject(subject), taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
72
+ });
73
+ }
74
+
75
+ function argsDigest(args) {
76
+ return crypto.createHash('sha256').update(JSON.stringify(args)).digest('hex').slice(0, 12);
77
+ }
78
+
79
+ // ---------------------------------------------------------------- filesystem
80
+
81
+ function decideFilesystem(bound, action, ctx) {
82
+ const write = action.kind === 'filesystem-write';
83
+ const subject = typeof action.path === 'string' ? action.path : '(not a string)';
84
+ const lex = lexicalPath(action.path);
85
+ if (!lex.ok) return result(bound, action, 'deny', 'path-invalid', subject);
86
+ // Any parent-directory segment is refused outright. Lexical normalization of
87
+ // `link/..` disagrees with what the kernel does when `link` is a symlink, so
88
+ // a path containing one cannot be judged by its normalized form.
89
+ if (lex.dotdot) return result(bound, action, 'deny', 'path-traversal', subject);
90
+ const real = canonicalPath(lex.path);
91
+ if (!real) return result(bound, action, 'deny', 'path-invalid', subject);
92
+
93
+ for (const p of ctx.protectedPaths || []) {
94
+ const pl = lexicalPath(p);
95
+ if (!pl.ok) continue;
96
+ const pr = canonicalPath(pl.path) ?? pl.path;
97
+ if (isWithin(real, pr) || isWithin(lex.path, pl.path)) return result(bound, action, 'deny', 'protected-path', subject);
98
+ }
99
+ const fsGrants = bound.manifest.filesystem;
100
+ const roots = write ? fsGrants.write : [...fsGrants.read, ...fsGrants.write];
101
+ if (!roots.length) return result(bound, action, 'deny', 'no-grant', subject);
102
+ const lexicallyIn = roots.some((r) => isWithin(lex.path, r));
103
+ if (!lexicallyIn) return result(bound, action, 'deny', 'outside-roots', subject);
104
+ const really = roots.some((r) => isWithin(real, canonicalPath(r) ?? r));
105
+ if (!really) return result(bound, action, 'deny', 'symlink-escape', subject);
106
+ return result(bound, action, 'allow', 'allowed', subject);
107
+ }
108
+
109
+ // ---------------------------------------------------------------- commands
110
+
111
+ function decideCommand(bound, action, ctx) {
112
+ const exe = action.executable;
113
+ const args = action.args === undefined ? [] : action.args;
114
+ const subjectBase = typeof exe === 'string' ? exe : '(not a string)';
115
+ const lexExe = lexicalPath(exe);
116
+ if (!lexExe.ok || lexExe.dotdot) return result(bound, action, 'deny', 'executable-not-absolute', subjectBase);
117
+ if (!Array.isArray(args) || args.length > MAX_ARGS || !args.every((a) => typeof a === 'string' && a.length <= MAX_ARG_LEN && !a.includes('\0'))) {
118
+ return result(bound, action, 'deny', 'args-invalid', `${subjectBase} (malformed arguments)`);
119
+ }
120
+ const subject = `${subjectBase} (${args.length} args, args sha256:${argsDigest(args)})`;
121
+
122
+ // Arguments are visible to every process on the host and to every descendant.
123
+ // A secret does not belong in one, whatever command it is handed to.
124
+ const canaries = (ctx.canaries || []).filter((c) => typeof c === 'string' && c.length >= 6);
125
+ for (const a of args) {
126
+ let secret = false;
127
+ try { secret = detectSecretShapes(a); } catch { secret = true; }
128
+ if (secret || canaries.some((c) => a.includes(c))) return result(bound, action, 'deny', 'secret-in-argument', subject);
129
+ }
130
+
131
+ let real; let st;
132
+ try { real = fs.realpathSync(lexExe.path); st = fs.statSync(real); } catch { return result(bound, action, 'deny', 'executable-unresolvable', subject); }
133
+ if (!st.isFile()) return result(bound, action, 'deny', 'executable-unresolvable', subject);
134
+ try { fs.accessSync(real, fs.constants.X_OK); } catch { return result(bound, action, 'deny', 'executable-unresolvable', subject); }
135
+
136
+ for (const w of bound.manifest.filesystem.write) {
137
+ if (isWithin(real, canonicalPath(w) ?? w)) return result(bound, action, 'deny', 'executable-in-writable-root', subject);
138
+ }
139
+
140
+ const entries = bound.manifest.commands.filter((c) => (canonicalPath(c.executable) ?? c.executable) === real);
141
+ if (!entries.length) return result(bound, action, 'deny', 'command-not-listed', subject);
142
+
143
+ const cls = classifyExecutable(real);
144
+ const matches = (e) => {
145
+ const v = e.args.values;
146
+ if (e.args.mode === 'any') return true;
147
+ if (e.args.mode === 'exact') return v.length === args.length && v.every((x, i) => x === args[i]);
148
+ return args.length >= v.length && v.every((x, i) => x === args[i]);
149
+ };
150
+ const argOk = entries.filter(matches);
151
+ if (!argOk.length) {
152
+ // An interpreter whose entries pin different arguments is the unpinned case.
153
+ if (cls.interpreter && !entries.some((e) => e.interpreter)) return result(bound, action, 'deny', 'interpreter-blocked', subject);
154
+ return result(bound, action, 'deny', 'args-not-permitted', subject);
155
+ }
156
+ if (cls.interpreter) {
157
+ const scoped = argOk.find((e) => e.interpreter === 'scoped');
158
+ if (!scoped) return result(bound, action, 'deny', 'interpreter-blocked', subject);
159
+ if (scoped.args.mode !== 'exact') return result(bound, action, 'deny', 'interpreter-args-unpinned', subject);
160
+ }
161
+ return result(bound, action, 'allow', 'allowed', subject);
162
+ }
163
+
164
+ // ---------------------------------------------------------------- network
165
+
166
+ function hostMatches(entryHost, host) {
167
+ if (entryHost === host) return true;
168
+ if (!entryHost.startsWith('*.')) return false;
169
+ const base = entryHost.slice(2);
170
+ return host !== base && host.endsWith(`.${base}`);
171
+ }
172
+
173
+ function decideNetwork(bound, action) {
174
+ const h = normalizeHost(action.host);
175
+ const subject = typeof action.host === 'string' ? `${action.scheme ?? '?'}://${action.host}:${action.port ?? '?'}` : '(not a string)';
176
+ if (!h.ok) return result(bound, action, 'deny', 'host-invalid', subject);
177
+ if (!Number.isInteger(action.port) || action.port < 1 || action.port > 65535) return result(bound, action, 'deny', 'port-invalid', subject);
178
+ const scheme = action.scheme === undefined ? 'https' : action.scheme;
179
+ if (scheme !== 'http' && scheme !== 'https') return result(bound, action, 'deny', 'scheme-not-declared', subject);
180
+ const onHost = bound.manifest.network.filter((d) => hostMatches(d.host, h.host));
181
+ if (!onHost.length) return result(bound, action, 'deny', 'destination-not-declared', subject);
182
+ const onPort = onHost.filter((d) => d.port === action.port);
183
+ if (!onPort.length) return result(bound, action, 'deny', 'port-not-declared', subject);
184
+ const onScheme = onPort.filter((d) => d.schemes.includes(scheme));
185
+ if (!onScheme.length) return result(bound, action, 'deny', 'scheme-not-declared', subject);
186
+
187
+ // Address checks apply to a host NAME once it has been resolved. The caller
188
+ // (the proxy) resolves once, passes the addresses here and connects to those
189
+ // same addresses, so what is checked is what is connected to.
190
+ const resolved = Array.isArray(action.resolvedAddresses) ? action.resolvedAddresses : null;
191
+ if (h.kind === 'name' && resolved) {
192
+ if (!resolved.length) return result(bound, action, 'deny', 'dns-private-address', subject);
193
+ const verdicts = onScheme.map((d) => {
194
+ if (d.addresses && !resolved.every((a) => d.addresses.includes(a))) return 'dns-changed';
195
+ if (!d.allowPrivateResolution && resolved.some((a) => classifyAddress(a) !== 'public')) return 'dns-private-address';
196
+ return 'ok';
197
+ });
198
+ if (!verdicts.includes('ok')) return result(bound, action, 'deny', verdicts.includes('dns-changed') ? 'dns-changed' : 'dns-private-address', subject);
199
+ }
200
+ return result(bound, action, 'allow', 'allowed', subject);
201
+ }
202
+
203
+ // ---------------------------------------------------------------- tools, delegation
204
+
205
+ function decideTool(bound, action) {
206
+ const subject = typeof action.tool === 'string' ? action.tool : '(not a string)';
207
+ if (typeof action.tool !== 'string' || !bound.manifest.tools.includes(action.tool)) return result(bound, action, 'deny', 'tool-not-declared', subject);
208
+ return result(bound, action, 'allow', 'allowed', subject);
209
+ }
210
+
211
+ function decideDelegation(bound, action) {
212
+ const d = bound.manifest.delegation;
213
+ const depth = Number.isInteger(action.depth) && action.depth >= 0 ? action.depth : 0;
214
+ const subject = `delegation at depth ${depth}`;
215
+ if (!d.allow) return result(bound, action, 'deny', 'delegation-not-allowed', subject);
216
+ if (depth >= d.maxDepth) return result(bound, action, 'deny', 'delegation-depth', subject);
217
+ if (action.request !== undefined) {
218
+ const child = deriveChild(bound.manifest, action.request);
219
+ if (!child.ok) return result(bound, action, 'deny', 'scope-expansion', subject);
220
+ }
221
+ return result(bound, action, 'allow', 'allowed', subject);
222
+ }
223
+
224
+ /**
225
+ * @param {{manifest: object, binding: object}} bound from `bindManifest`
226
+ * @param {object} action `{ kind, ... }`; kind is one of CAPABILITY_KINDS
227
+ * @param {object} ctx
228
+ * @param {{taskId:string, revision:string, policyVersion:number}} ctx.binding what the caller believes it is acting for
229
+ * @param {string[]} [ctx.protectedPaths] paths no task may touch, whatever the manifest says
230
+ * @param {string[]} [ctx.canaries] values that must never appear in an argument
231
+ */
232
+ export function decide(bound, action, ctx = {}) {
233
+ if (!bound || !bound.manifest || !bound.binding) return result(bound, action, 'deny', 'invalid-manifest', '(no manifest)');
234
+ const b = ctx.binding;
235
+ const bb = bound.binding;
236
+ if (!b || b.taskId !== bb.taskId || b.revision !== bb.revision || b.policyVersion !== bb.policyVersion) {
237
+ return result(bound, action, 'deny', 'binding-mismatch', '(binding)');
238
+ }
239
+ if (!action || typeof action !== 'object' || Array.isArray(action) || !CAPABILITY_KINDS.includes(action.kind)) {
240
+ return result(bound, action, 'deny', 'unknown-action', typeof action?.kind === 'string' ? action.kind : '(no kind)');
241
+ }
242
+ try {
243
+ switch (action.kind) {
244
+ case 'filesystem-read':
245
+ case 'filesystem-write': return decideFilesystem(bound, action, ctx);
246
+ case 'command': return decideCommand(bound, action, ctx);
247
+ case 'network': return decideNetwork(bound, action);
248
+ case 'tool': return decideTool(bound, action);
249
+ case 'delegation': return decideDelegation(bound, action);
250
+ default: return result(bound, action, 'deny', 'unknown-action', action.kind);
251
+ }
252
+ } catch {
253
+ // A decision never throws. An unexpected failure is a denial.
254
+ return result(bound, action, 'deny', 'invalid-manifest', '(decision failed)');
255
+ }
256
+ }
@@ -0,0 +1,69 @@
1
+ // Delegation under the capability policy (X-505.AC02).
2
+ //
3
+ // A task that hands work to another agent gives it a CHILD manifest, and a child
4
+ // can only be a subset of its parent. `delegate` is the one door:
5
+ //
6
+ // 1. the parent's own manifest must allow delegation at all, and the delegation
7
+ // depth is read from the manifest chain (every hop must carry a strictly
8
+ // lower `maxDepth`), never from a depth the requester claims
9
+ // 2. the request is derived through `deriveChild`: any widening of roots,
10
+ // commands, destinations, tools, limits, delegation, revision or policy
11
+ // version is `SCOPE_EXPANSION` and yields NO manifest (no partial grant)
12
+ // 3. tool names are compared exactly, so an alias (another case, a prefix, a
13
+ // namespaced spelling) is a different, undeclared tool and is refused
14
+ // 4. a child task id is issued once: asking again with the same id is
15
+ // idempotent when the request is identical and refused when it differs, so
16
+ // a retry with changed instructions cannot swap in a wider grant
17
+ //
18
+ // What a child is told in prose does not matter to any of this: the manifest is
19
+ // derived once, frozen, and bound to the child's own task id, revision, policy
20
+ // version and digest.
21
+ import { bindManifest, deriveChild } from './manifest.js';
22
+ import { mediate } from './recovery.js';
23
+ import { reasonText } from './reasons.js';
24
+ import { digestOf } from '../posture/assurance/identity.js';
25
+
26
+ /** Remembers which child ids were issued for which manifest digest. Held by the delegating controller. */
27
+ export function createDelegationRegistry() {
28
+ const issued = new Map();
29
+ return Object.freeze({
30
+ get: (id) => issued.get(id) ?? null,
31
+ set: (id, digest) => { issued.set(id, digest); },
32
+ size: () => issued.size,
33
+ });
34
+ }
35
+
36
+ function refuse(code, base) {
37
+ return Object.freeze({
38
+ ok: false, status: 'blocked', code, reason: reasonText(code), child: null, errors: [],
39
+ decision: base?.decision ?? null, proposal: base?.proposal ?? null, missing: base?.missing ?? null,
40
+ attemptsRemaining: base?.attemptsRemaining ?? null,
41
+ });
42
+ }
43
+
44
+ /**
45
+ * @param {{manifest: object, binding: object}} parent the delegating task's bound manifest
46
+ * @param {object} request manifest-shaped, `taskId` required (see `deriveChild`)
47
+ * @param {object} o
48
+ * @param {{taskId:string,revision:string,policyVersion:number}} o.binding identity the caller acts for
49
+ * @param {object} [o.registry] from `createDelegationRegistry`
50
+ * @param {object} [o.guard] denial guard (recovery.js)
51
+ */
52
+ export function delegate(parent, request, { binding, registry, guard } = {}) {
53
+ // Depth comes from the manifest chain; the request cannot claim a shallower one.
54
+ const m = mediate(parent, { kind: 'delegation', depth: 0, request }, { binding }, { guard });
55
+ if (m.status !== 'ok') return refuse(m.decision.code, m);
56
+ const derived = deriveChild(parent.manifest, request);
57
+ if (!derived.ok) return Object.freeze({ ...refuse('scope-expansion', m), errors: derived.errors.map((e) => ({ code: e.code, path: e.path })) });
58
+ const bound = bindManifest(derived.manifest);
59
+ if (!bound.ok) return Object.freeze({ ...refuse('invalid-manifest', m), errors: bound.errors.map((e) => ({ code: e.code, path: e.path })) });
60
+ if (registry) {
61
+ const prior = registry.get(bound.bound.binding.taskId);
62
+ if (prior !== null && prior !== bound.bound.binding.digest) return refuse('scope-expansion', m);
63
+ registry.set(bound.bound.binding.taskId, bound.bound.binding.digest);
64
+ }
65
+ return Object.freeze({
66
+ ok: true, status: 'ok', code: null, reason: null, errors: [], decision: m.decision,
67
+ child: bound.bound, parentTaskId: parent.binding.taskId, requestDigest: digestOf(request),
68
+ });
69
+ }
@@ -0,0 +1,20 @@
1
+ // Manifest-aware egress decisions (X-501: manifests are consumed by the egress
2
+ // policy). A call to a declared model endpoint must satisfy BOTH the task's
3
+ // network grant and the operator's egress policy (egress/policy.js); either one
4
+ // denying is a denial, and the result names which one. No payload is involved,
5
+ // so the decision is as safe to log as any other.
6
+ import { decide } from './decide.js';
7
+ import { evaluateEgress } from '../egress/policy.js';
8
+
9
+ export function evaluateManifestEgress(bound, binding, egressCtx = {}) {
10
+ let u;
11
+ try { u = new URL(String(egressCtx.endpoint)); } catch { u = null; }
12
+ if (!u) return Object.freeze({ allowed: false, by: 'capability-manifest', code: 'host-invalid' });
13
+ const port = u.port ? Number(u.port) : (u.protocol === 'http:' ? 80 : 443);
14
+ const scheme = u.protocol === 'http:' ? 'http' : 'https';
15
+ const d = decide(bound, { kind: 'network', host: u.hostname, port, scheme }, { binding });
16
+ if (d.decision !== 'allow') return Object.freeze({ allowed: false, by: 'capability-manifest', code: d.code });
17
+ const e = evaluateEgress(egressCtx);
18
+ if (!e.allowed) return Object.freeze({ allowed: false, by: 'egress-policy', code: 'egress-denied', policy: e });
19
+ return Object.freeze({ allowed: true, by: null, code: 'allowed', policy: e });
20
+ }
@@ -0,0 +1,50 @@
1
+ // What a hook says about a tool use (X-505.AC01).
2
+ //
3
+ // The pre-edit, pre-bash and dispatch hooks, and the MCP tool gate, ask the SAME
4
+ // function (`decide`) the runner asks. A hook's answer is advice: the record is
5
+ // `hook-advisory`, `enforced` is always false, and a hook never blocks on it. The
6
+ // runner is what stops a task; a hook only explains why it would.
7
+ //
8
+ // Tool names here are the names the HOST agent uses (`mcp__<server>__<tool>`,
9
+ // `Task`, `Edit` ...), compared exactly. The MCP server's own gate sees the short
10
+ // tool names. A manifest declares the spelling it means; there is no aliasing, so
11
+ // `mcp__other__apply_fix` is not `apply_fix`.
12
+ import { advise } from './records.js';
13
+
14
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
15
+ const DELEGATE_TOOLS = new Set(['Task', 'Agent']);
16
+
17
+ /** The capability action a host tool use amounts to, or null when this layer has no opinion. */
18
+ export function actionForToolUse(evt) {
19
+ const tool = evt && (evt.tool_name || evt.toolName);
20
+ const input = (evt && evt.tool_input) || {};
21
+ if (typeof tool !== 'string') return null;
22
+ if (EDIT_TOOLS.has(tool)) {
23
+ const p = input.file_path ?? input.notebook_path;
24
+ return { kind: 'filesystem-write', path: typeof p === 'string' ? p : undefined };
25
+ }
26
+ if (tool === 'Bash') return { kind: 'command', command: typeof input.command === 'string' ? input.command : '' };
27
+ if (DELEGATE_TOOLS.has(tool)) return { kind: 'delegation', depth: 0 };
28
+ if (tool.startsWith('mcp__')) return { kind: 'tool', tool };
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * @param {{manifest:object,binding:object}} bound
34
+ * @param {object} evt a PreToolUse event
35
+ * @param {{binding: object}} ctx the identity the session claims
36
+ * @returns {{advisory:true, enforced:false, decision:string, code:string, lines:string[], record:object|null}|null}
37
+ */
38
+ export function adviseToolUse(bound, evt, ctx) {
39
+ const action = actionForToolUse(evt);
40
+ if (!action) return null;
41
+ const a = advise(bound, action, ctx);
42
+ const tool = String(evt.tool_name || evt.toolName);
43
+ const lines = [];
44
+ if (a.decision !== 'allow') {
45
+ lines.push(`agentic-security capability advisory (not enforced): ${tool} -> ${a.decision} (${a.code})`);
46
+ lines.push(` ${a.note}`);
47
+ if (action.kind === 'delegation') lines.push(' a delegated agent can only receive a subset of this task\'s capabilities (capabilities/delegate.js)');
48
+ }
49
+ return Object.freeze({ advisory: true, enforced: false, decision: a.decision, code: a.code, lines, record: a.record });
50
+ }
@@ -0,0 +1,32 @@
1
+ // Public surface of the capability facility (X-501 to X-504). Consumers import
2
+ // from here; nothing in the default scan or report path does.
3
+ export {
4
+ MANIFEST_SCHEMA, MANIFEST_SCHEMA_VERSION, ARG_MODES, RESOURCE_RANGES,
5
+ validateManifest, bindManifest, manifestDigest, deriveChild,
6
+ } from './manifest.js';
7
+ export { decide, classifyExecutable } from './decide.js';
8
+ export { REASONS, REASON_CODES, reasonText, sanitizeSubject } from './reasons.js';
9
+ export { toCapabilityDecisionRecord, advise } from './records.js';
10
+ export { redactOutbound, sanitizeLogText, destinationClass, denialRecord, recordNetworkDenial } from './outbound.js';
11
+ export { startMediationProxy } from './proxy.js';
12
+ export {
13
+ CAPABILITY_CONTROLS, probeCapabilityControls, requiredControlsFor, isAdvertisedBackend, platformStatements,
14
+ resetCapabilityProbeCache,
15
+ } from './probes.js';
16
+ export { buildCapabilityReport, REPORT_LIMITATIONS } from './report.js';
17
+ export { runCapabilityTask } from './runner.js';
18
+ export { evaluateManifestEgress } from './egress.js';
19
+ // X-505 to X-508.
20
+ export { TOOL_CAPABILITIES, toolCapabilityFor, isMutatingOrExternal } from './tool-registry.js';
21
+ export { createToolGate, featureEnabledByOperator } from './tool-gate.js';
22
+ export { delegate, createDelegationRegistry } from './delegate.js';
23
+ export { actionForToolUse, adviseToolUse } from './hook-advice.js';
24
+ export {
25
+ proposeChange, mediate, createDenialGuard, signPolicyGrant, applyPolicyChange, applyChangeToManifest, createPolicyLedger,
26
+ verifyLedgerEntries, DEFAULT_RETRY_LIMIT, MAX_RETRY_LIMIT,
27
+ } from './recovery.js';
28
+ export {
29
+ createReceiptRecorder, receiptsFromRun, verifyChainIntegrity, assessCompleteness, signReceiptChain, verifyReceiptEnvelope,
30
+ receiptReport, receiptDirectoryConflict, writeReceiptEnvelope, readReceiptEnvelope,
31
+ } from './receipts.js';
32
+ export { ATTACK_CLASSES, KNOWN_LIMITS, buildAttackCoverage, enforcedModeReleaseGate } from './attack-coverage.js';