@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +34 -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 +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  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,344 @@
1
+ // Task capability manifests (X-501).
2
+ //
3
+ // A manifest is the complete statement of what one task may do: readable and
4
+ // writable roots, structured commands, network destinations, tool actions,
5
+ // resource limits and whether it may delegate. It is
6
+ //
7
+ // deny-by-default a section that is absent grants nothing; there is no
8
+ // wildcard that means "everything", and no field whose
9
+ // default is more permissive than its absence
10
+ // closed-world an unknown field, an unknown schema version, a relative
11
+ // path, a wildcard host with no domain behind it or a value
12
+ // of the wrong type invalidates the whole manifest; nothing
13
+ // is carried through, ignored or defaulted open
14
+ // bound to a task id, an exact repository revision and a policy
15
+ // version, carried as `binding` with the manifest digest, so
16
+ // a grant made for one task, revision or policy version
17
+ // cannot be replayed for another (decide.js checks it)
18
+ // monotonic `deriveChild` builds a child manifest only from a subset
19
+ // of the parent's grants; anything wider is `SCOPE_EXPANSION`
20
+ // and no child manifest is produced
21
+ //
22
+ // Pure data except for `deriveChild`, which compares canonical (symlink-resolved)
23
+ // paths so a link inside a granted root cannot widen a child.
24
+ import path from 'node:path';
25
+ import {
26
+ result, guardObject, checkHeader, checkFields, checkString, checkCommit, isPlainObject,
27
+ } from '../posture/assurance/schema-kit.js';
28
+ import { digestOf } from '../posture/assurance/identity.js';
29
+ import { LIMITS } from '../posture/assurance/config.js';
30
+ import { lexicalPath, canonicalPath, isWithin } from './paths.js';
31
+ import { normalizeHost } from './address.js';
32
+
33
+ export const MANIFEST_SCHEMA = 'agentic-security/capability-manifest';
34
+ export const MANIFEST_SCHEMA_VERSION = '1.0.0';
35
+ export const ARG_MODES = Object.freeze(['exact', 'prefix', 'any']);
36
+ export const NETWORK_SCHEMES = Object.freeze(['http', 'https']);
37
+ export const MAX_DELEGATION_DEPTH = 8;
38
+
39
+ const MANIFEST_ALLOWED = [
40
+ 'schema', 'schemaVersion', 'taskId', 'parentTaskId', 'repository', 'policyVersion', 'filesystem', 'commands',
41
+ 'network', 'tools', 'resources', 'delegation', 'createdAt',
42
+ ];
43
+ const MANIFEST_REQUIRED = ['schema', 'schemaVersion', 'taskId', 'repository', 'policyVersion'];
44
+
45
+ // Resource limits a manifest can carry. The first three come straight from the
46
+ // assurance config (same ranges), the file-size cap is enforced by the sandbox
47
+ // prelude, and the last two are CARRIED but never claimed enforced: see
48
+ // `RESOURCE_ENFORCEMENT` in runner.js.
49
+ export const RESOURCE_RANGES = Object.freeze({
50
+ timeoutMs: { min: LIMITS.timeoutMs.min, max: LIMITS.timeoutMs.max },
51
+ maxOutputBytes: { min: LIMITS.maxOutputBytes.min, max: LIMITS.maxOutputBytes.max },
52
+ maxFileSizeKb: { min: 1, max: 1_048_576 },
53
+ maxProcesses: { min: 1, max: 4096 },
54
+ maxMemoryMiB: { min: LIMITS.maxMemoryMiB.min, max: LIMITS.maxMemoryMiB.max },
55
+ });
56
+
57
+ const TOOL_NAME = /^[A-Za-z0-9_.:-]{1,80}$/;
58
+ const MAX_LIST = 256;
59
+ const MAX_ARGS = 256;
60
+ const MAX_ARG_LEN = 8192;
61
+
62
+ function checkPathList(ctx, where, list) {
63
+ if (list === undefined) return [];
64
+ if (!Array.isArray(list) || list.length > MAX_LIST) { ctx.err('BAD_TYPE', where, `must be an array of at most ${MAX_LIST} absolute paths`); return []; }
65
+ const out = [];
66
+ list.forEach((p, i) => {
67
+ const lex = lexicalPath(p);
68
+ if (!lex.ok) { ctx.err('BAD_PATH', `${where}[${i}]`, 'must be an absolute path without NUL bytes'); return; }
69
+ if (lex.dotdot) { ctx.err('BAD_PATH', `${where}[${i}]`, 'must not contain parent-directory segments'); return; }
70
+ if (lex.path === path.sep) { ctx.err('BAD_PATH', `${where}[${i}]`, 'the filesystem root cannot be granted'); return; }
71
+ out.push(lex.path);
72
+ });
73
+ return [...new Set(out)].sort();
74
+ }
75
+
76
+ function checkCommands(ctx, list) {
77
+ if (list === undefined) return [];
78
+ if (!Array.isArray(list) || list.length > MAX_LIST) { ctx.err('BAD_TYPE', 'commands', `must be an array of at most ${MAX_LIST} entries`); return []; }
79
+ const out = [];
80
+ list.forEach((c, i) => {
81
+ const w = `commands[${i}]`;
82
+ if (!isPlainObject(c)) { ctx.err('BAD_TYPE', w, 'must be an object'); return; }
83
+ for (const k of Object.keys(c)) if (!['executable', 'args', 'interpreter'].includes(k)) ctx.err('UNKNOWN_FIELD', `${w}.${k}`, 'not part of a command entry');
84
+ const lex = lexicalPath(c.executable);
85
+ if (!lex.ok || lex.dotdot) { ctx.err('BAD_PATH', `${w}.executable`, 'must be an absolute path without parent-directory segments'); return; }
86
+ let args = { mode: 'exact', values: [] };
87
+ if (c.args !== undefined) {
88
+ if (!isPlainObject(c.args)) { ctx.err('BAD_TYPE', `${w}.args`, 'must be an object {mode, values}'); return; }
89
+ for (const k of Object.keys(c.args)) if (!['mode', 'values'].includes(k)) ctx.err('UNKNOWN_FIELD', `${w}.args.${k}`, 'not part of args');
90
+ if (!ARG_MODES.includes(c.args.mode)) { ctx.err('UNKNOWN_ENUM', `${w}.args.mode`, `must be one of: ${ARG_MODES.join(', ')}`); return; }
91
+ const values = c.args.values === undefined ? [] : c.args.values;
92
+ const okValues = Array.isArray(values) && values.length <= MAX_ARGS
93
+ && values.every((v) => typeof v === 'string' && v.length <= MAX_ARG_LEN && !v.includes('\0'));
94
+ if (!okValues) { ctx.err('BAD_TYPE', `${w}.args.values`, 'must be an array of plain strings within the size limits'); return; }
95
+ if (c.args.mode === 'any' && values.length) { ctx.err('RULE_VIOLATION', `${w}.args.values`, `mode 'any' takes no values`); return; }
96
+ args = { mode: c.args.mode, values: [...values] };
97
+ }
98
+ let interpreter = null;
99
+ if (c.interpreter !== undefined && c.interpreter !== null) {
100
+ if (c.interpreter !== 'scoped') { ctx.err('UNKNOWN_ENUM', `${w}.interpreter`, `must be 'scoped' when present`); return; }
101
+ if (args.mode !== 'exact') { ctx.err('RULE_VIOLATION', `${w}.interpreter`, `a scoped interpreter must pin its exact arguments (mode 'exact')`); return; }
102
+ interpreter = 'scoped';
103
+ }
104
+ out.push({ executable: lex.path, args, interpreter });
105
+ });
106
+ const key = (c) => `${c.executable}\0${c.args.mode}\0${JSON.stringify(c.args.values)}\0${c.interpreter}`;
107
+ const seen = new Map(out.map((c) => [key(c), c]));
108
+ return [...seen.values()].sort((a, b) => (key(a) < key(b) ? -1 : 1));
109
+ }
110
+
111
+ function checkNetwork(ctx, list) {
112
+ if (list === undefined) return [];
113
+ if (!Array.isArray(list) || list.length > MAX_LIST) { ctx.err('BAD_TYPE', 'network', `must be an array of at most ${MAX_LIST} entries`); return []; }
114
+ const out = [];
115
+ list.forEach((d, i) => {
116
+ const w = `network[${i}]`;
117
+ if (!isPlainObject(d)) { ctx.err('BAD_TYPE', w, 'must be an object'); return; }
118
+ for (const k of Object.keys(d)) if (!['host', 'port', 'schemes', 'allowPrivateResolution', 'addresses'].includes(k)) ctx.err('UNKNOWN_FIELD', `${w}.${k}`, 'not part of a network entry');
119
+ if (typeof d.host !== 'string') { ctx.err('BAD_TYPE', `${w}.host`, 'must be a string'); return; }
120
+ let wildcard = false;
121
+ let hostText = d.host;
122
+ if (hostText.startsWith('*.')) { wildcard = true; hostText = hostText.slice(2); }
123
+ const h = normalizeHost(hostText);
124
+ if (!h.ok) { ctx.err('BAD_TYPE', `${w}.host`, 'must be a host name, an IP address or a *.domain wildcard'); return; }
125
+ if (wildcard && (h.kind !== 'name' || h.host.split('.').length < 2)) {
126
+ ctx.err('RULE_VIOLATION', `${w}.host`, 'a wildcard needs a registrable domain behind it (*.example.com), never a bare suffix or an address');
127
+ return;
128
+ }
129
+ if (!Number.isInteger(d.port) || d.port < 1 || d.port > 65535) { ctx.err('BAD_TYPE', `${w}.port`, 'must be an integer from 1 to 65535; wildcard ports do not exist'); return; }
130
+ const schemes = d.schemes === undefined ? ['https'] : d.schemes;
131
+ if (!Array.isArray(schemes) || !schemes.length || !schemes.every((s) => NETWORK_SCHEMES.includes(s))) {
132
+ ctx.err('UNKNOWN_ENUM', `${w}.schemes`, `must be a non-empty subset of: ${NETWORK_SCHEMES.join(', ')}`); return;
133
+ }
134
+ if (d.allowPrivateResolution !== undefined && typeof d.allowPrivateResolution !== 'boolean') { ctx.err('BAD_TYPE', `${w}.allowPrivateResolution`, 'must be a boolean'); return; }
135
+ let addresses = null;
136
+ if (d.addresses !== undefined && d.addresses !== null) {
137
+ const ok = Array.isArray(d.addresses) && d.addresses.length > 0 && d.addresses.length <= 32
138
+ && d.addresses.every((a) => { const n = normalizeHost(a); return n.ok && n.kind !== 'name'; });
139
+ if (!ok) { ctx.err('BAD_TYPE', `${w}.addresses`, 'must be a non-empty array of IP addresses'); return; }
140
+ addresses = [...new Set(d.addresses.map((a) => normalizeHost(a).host))].sort();
141
+ }
142
+ out.push({
143
+ host: `${wildcard ? '*.' : ''}${h.host}`, port: d.port, schemes: [...new Set(schemes)].sort(),
144
+ allowPrivateResolution: d.allowPrivateResolution === true, addresses,
145
+ });
146
+ });
147
+ const key = (n) => JSON.stringify(n);
148
+ const seen = new Map(out.map((n) => [key(n), n]));
149
+ return [...seen.values()].sort((a, b) => (key(a) < key(b) ? -1 : 1));
150
+ }
151
+
152
+ function checkResources(ctx, r) {
153
+ if (r === undefined) return {};
154
+ if (!isPlainObject(r)) { ctx.err('BAD_TYPE', 'resources', 'must be an object'); return {}; }
155
+ const out = {};
156
+ for (const [k, v] of Object.entries(r)) {
157
+ const range = RESOURCE_RANGES[k];
158
+ if (!range) { ctx.err('UNKNOWN_FIELD', `resources.${k}`, 'not a known resource limit'); continue; }
159
+ if (!Number.isInteger(v) || v < range.min || v > range.max) { ctx.err('BAD_TYPE', `resources.${k}`, `must be an integer from ${range.min} to ${range.max}`); continue; }
160
+ out[k] = v;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function checkDelegation(ctx, d) {
166
+ if (d === undefined) return { allow: false, maxDepth: 0 };
167
+ if (!isPlainObject(d)) { ctx.err('BAD_TYPE', 'delegation', 'must be an object'); return { allow: false, maxDepth: 0 }; }
168
+ for (const k of Object.keys(d)) if (!['allow', 'maxDepth'].includes(k)) ctx.err('UNKNOWN_FIELD', `delegation.${k}`, 'not part of delegation');
169
+ if (typeof d.allow !== 'boolean') { ctx.err('BAD_TYPE', 'delegation.allow', 'must be a boolean'); return { allow: false, maxDepth: 0 }; }
170
+ const maxDepth = d.maxDepth === undefined ? (d.allow ? 1 : 0) : d.maxDepth;
171
+ if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > MAX_DELEGATION_DEPTH) { ctx.err('BAD_TYPE', 'delegation.maxDepth', `must be an integer from 0 to ${MAX_DELEGATION_DEPTH}`); return { allow: false, maxDepth: 0 }; }
172
+ if (!d.allow && maxDepth !== 0) { ctx.err('RULE_VIOLATION', 'delegation.maxDepth', 'a task that may not delegate has depth 0'); return { allow: false, maxDepth: 0 }; }
173
+ if (d.allow && maxDepth === 0) { ctx.err('RULE_VIOLATION', 'delegation.maxDepth', 'a task that may delegate needs a depth of at least 1'); return { allow: false, maxDepth: 0 }; }
174
+ return { allow: d.allow, maxDepth };
175
+ }
176
+
177
+ /**
178
+ * Validate and normalize a manifest. Returns `{ ok, errors, manifest }`;
179
+ * `manifest` is the frozen normalized form and is present only when `ok`.
180
+ * Never throws.
181
+ */
182
+ export function validateManifest(m) {
183
+ const g = guardObject(m);
184
+ const ctx = g.ctx;
185
+ if (!g.ok) return { ...result(ctx), manifest: null };
186
+ if (!checkHeader(ctx, m, MANIFEST_SCHEMA)) return { ...result(ctx), manifest: null };
187
+ checkFields(ctx, m, MANIFEST_ALLOWED, MANIFEST_REQUIRED);
188
+ checkString(ctx, 'taskId', m.taskId);
189
+ if (m.parentTaskId !== undefined && m.parentTaskId !== null) checkString(ctx, 'parentTaskId', m.parentTaskId);
190
+ if (!isPlainObject(m.repository)) ctx.err('BAD_TYPE', 'repository', 'must be an object {revision}');
191
+ else {
192
+ for (const k of Object.keys(m.repository)) if (k !== 'revision') ctx.err('UNKNOWN_FIELD', `repository.${k}`, 'not part of repository');
193
+ checkCommit(ctx, 'repository.revision', m.repository.revision ?? null, { nullable: false });
194
+ }
195
+ if (!Number.isInteger(m.policyVersion) || m.policyVersion < 1) ctx.err('BAD_TYPE', 'policyVersion', 'must be a positive integer');
196
+
197
+ let fsRead = []; let fsWrite = [];
198
+ if (m.filesystem !== undefined) {
199
+ if (!isPlainObject(m.filesystem)) ctx.err('BAD_TYPE', 'filesystem', 'must be an object {read, write}');
200
+ else {
201
+ for (const k of Object.keys(m.filesystem)) if (!['read', 'write'].includes(k)) ctx.err('UNKNOWN_FIELD', `filesystem.${k}`, 'not part of filesystem');
202
+ fsRead = checkPathList(ctx, 'filesystem.read', m.filesystem.read);
203
+ fsWrite = checkPathList(ctx, 'filesystem.write', m.filesystem.write);
204
+ }
205
+ }
206
+ const commands = checkCommands(ctx, m.commands);
207
+ const network = checkNetwork(ctx, m.network);
208
+ let tools = [];
209
+ if (m.tools !== undefined) {
210
+ if (!Array.isArray(m.tools) || m.tools.length > MAX_LIST || !m.tools.every((t) => typeof t === 'string' && TOOL_NAME.test(t))) {
211
+ ctx.err('BAD_TYPE', 'tools', `must be an array of tool names (${TOOL_NAME})`);
212
+ } else tools = [...new Set(m.tools)].sort();
213
+ }
214
+ const resources = checkResources(ctx, m.resources);
215
+ const delegation = checkDelegation(ctx, m.delegation);
216
+ if (ctx.errors.length) return { ...result(ctx), manifest: null };
217
+
218
+ const manifest = {
219
+ schema: MANIFEST_SCHEMA, schemaVersion: m.schemaVersion, taskId: m.taskId,
220
+ parentTaskId: m.parentTaskId ?? null,
221
+ repository: { revision: m.repository.revision }, policyVersion: m.policyVersion,
222
+ filesystem: { read: fsRead, write: fsWrite }, commands, network, tools, resources, delegation,
223
+ };
224
+ return { ok: true, errors: [], manifest: deepFreeze(manifest) };
225
+ }
226
+
227
+ function deepFreeze(o) {
228
+ if (o && typeof o === 'object' && !Object.isFrozen(o)) {
229
+ Object.freeze(o);
230
+ for (const v of Object.values(o)) deepFreeze(v);
231
+ }
232
+ return o;
233
+ }
234
+
235
+ /** Digest of the normalized manifest: the policy identity a receipt binds to. */
236
+ export function manifestDigest(manifest) {
237
+ return digestOf(manifest);
238
+ }
239
+
240
+ /**
241
+ * Validate and bind. The returned object is what every decision and the runner
242
+ * consume: the normalized manifest plus the binding it is valid for.
243
+ */
244
+ export function bindManifest(m) {
245
+ const v = validateManifest(m);
246
+ if (!v.ok) return { ok: false, errors: v.errors, bound: null };
247
+ const bound = {
248
+ manifest: v.manifest,
249
+ binding: Object.freeze({
250
+ taskId: v.manifest.taskId, revision: v.manifest.repository.revision,
251
+ policyVersion: v.manifest.policyVersion, digest: manifestDigest(v.manifest),
252
+ }),
253
+ };
254
+ return { ok: true, errors: [], bound: Object.freeze(bound) };
255
+ }
256
+
257
+ // ---------------------------------------------------------------- inheritance
258
+
259
+ function canon(p) { return canonicalPath(p) ?? p; }
260
+
261
+ function rootCovered(child, parentRoots) {
262
+ const c = canon(child);
263
+ return parentRoots.some((r) => {
264
+ const pr = canon(r);
265
+ return isWithin(c, pr) && isWithin(child, r);
266
+ });
267
+ }
268
+
269
+ function hostCovered(child, parent) {
270
+ if (child === parent) return true;
271
+ if (!parent.startsWith('*.')) return false;
272
+ const base = parent.slice(2);
273
+ const c = child.startsWith('*.') ? child.slice(2) : child;
274
+ return c !== base && c.endsWith(`.${base}`);
275
+ }
276
+
277
+ function networkCovered(c, p) {
278
+ if (!hostCovered(c.host, p.host) || c.port !== p.port) return false;
279
+ if (!c.schemes.every((s) => p.schemes.includes(s))) return false;
280
+ if (c.allowPrivateResolution && !p.allowPrivateResolution) return false;
281
+ if (p.addresses) { if (!c.addresses || !c.addresses.every((a) => p.addresses.includes(a))) return false; }
282
+ return true;
283
+ }
284
+
285
+ function commandCovered(c, p) {
286
+ if (canon(c.executable) !== canon(p.executable)) return false;
287
+ if (c.interpreter && !p.interpreter) return false;
288
+ const pv = p.args.values; const cv = c.args.values;
289
+ if (p.args.mode === 'any') return true;
290
+ if (p.args.mode === 'exact') return c.args.mode === 'exact' && cv.length === pv.length && cv.every((v, i) => v === pv[i]);
291
+ // parent prefix
292
+ return c.args.mode !== 'any' && cv.length >= pv.length && pv.every((v, i) => v === cv[i]);
293
+ }
294
+
295
+ /**
296
+ * Build a child task's manifest from a request, only if it is a subset of the
297
+ * parent's. Omitted repository, policy version and resource limits are
298
+ * inherited; anything else that is omitted is simply not granted. Returns
299
+ * `{ ok, errors, manifest }`; on any widening `ok` is false with
300
+ * `SCOPE_EXPANSION` errors and NO manifest, so there is no partial grant.
301
+ *
302
+ * @param {object} parent a normalized manifest (from `validateManifest`/`bindManifest`)
303
+ * @param {object} request manifest-shaped; `taskId` required
304
+ */
305
+ export function deriveChild(parent, request) {
306
+ const errors = [];
307
+ const pv = validateManifest(parent);
308
+ if (!pv.ok) return { ok: false, errors: [{ code: 'RULE_VIOLATION', path: 'parent', message: 'the parent manifest is invalid' }], manifest: null };
309
+ const p = pv.manifest;
310
+ if (!isPlainObject(request)) return { ok: false, errors: [{ code: 'NOT_AN_OBJECT', path: '', message: 'request must be an object' }], manifest: null };
311
+
312
+ const merged = { ...request, schema: MANIFEST_SCHEMA, schemaVersion: request.schemaVersion ?? p.schemaVersion };
313
+ merged.parentTaskId = p.taskId;
314
+ if (request.parentTaskId !== undefined && request.parentTaskId !== null && request.parentTaskId !== p.taskId) {
315
+ errors.push({ code: 'SCOPE_EXPANSION', path: 'parentTaskId', message: 'a child names its actual parent' });
316
+ }
317
+ merged.repository = request.repository ?? { revision: p.repository.revision };
318
+ merged.policyVersion = request.policyVersion ?? p.policyVersion;
319
+ if (merged.repository?.revision !== p.repository.revision) errors.push({ code: 'SCOPE_EXPANSION', path: 'repository.revision', message: 'a child is bound to its parent\'s repository revision' });
320
+ if (merged.policyVersion !== p.policyVersion) errors.push({ code: 'SCOPE_EXPANSION', path: 'policyVersion', message: 'a child is bound to its parent\'s policy version' });
321
+ if (request.taskId === p.taskId) errors.push({ code: 'SCOPE_EXPANSION', path: 'taskId', message: 'a child has its own task id' });
322
+ // Inherit limits the parent set; the child may only lower them.
323
+ merged.resources = { ...(p.resources), ...(isPlainObject(request.resources) ? request.resources : {}) };
324
+
325
+ const cv = validateManifest(merged);
326
+ if (!cv.ok) return { ok: false, errors: [...errors, ...cv.errors], manifest: null };
327
+ const c = cv.manifest;
328
+
329
+ const parentReadable = [...p.filesystem.read, ...p.filesystem.write];
330
+ c.filesystem.read.forEach((r, i) => { if (!rootCovered(r, parentReadable)) errors.push({ code: 'SCOPE_EXPANSION', path: `filesystem.read[${i}]`, message: 'not inside a root the parent may read' }); });
331
+ c.filesystem.write.forEach((r, i) => { if (!rootCovered(r, p.filesystem.write)) errors.push({ code: 'SCOPE_EXPANSION', path: `filesystem.write[${i}]`, message: 'not inside a root the parent may write' }); });
332
+ c.commands.forEach((cmd, i) => { if (!p.commands.some((pc) => commandCovered(cmd, pc))) errors.push({ code: 'SCOPE_EXPANSION', path: `commands[${i}]`, message: 'not covered by a command the parent may run' }); });
333
+ c.network.forEach((n, i) => { if (!p.network.some((pn) => networkCovered(n, pn))) errors.push({ code: 'SCOPE_EXPANSION', path: `network[${i}]`, message: 'not covered by a destination the parent may reach' }); });
334
+ c.tools.forEach((t, i) => { if (!p.tools.includes(t)) errors.push({ code: 'SCOPE_EXPANSION', path: `tools[${i}]`, message: 'not a tool the parent may use' }); });
335
+ for (const [k, v] of Object.entries(c.resources)) {
336
+ if (p.resources[k] !== undefined && v > p.resources[k]) errors.push({ code: 'SCOPE_EXPANSION', path: `resources.${k}`, message: 'higher than the parent limit' });
337
+ }
338
+ if (c.delegation.allow) {
339
+ if (!p.delegation.allow) errors.push({ code: 'SCOPE_EXPANSION', path: 'delegation.allow', message: 'the parent may not delegate' });
340
+ else if (c.delegation.maxDepth > p.delegation.maxDepth - 1) errors.push({ code: 'SCOPE_EXPANSION', path: 'delegation.maxDepth', message: 'must be lower than the parent depth' });
341
+ }
342
+ if (errors.length) return { ok: false, errors, manifest: null };
343
+ return { ok: true, errors: [], manifest: c };
344
+ }
@@ -0,0 +1,189 @@
1
+ // Outbound payload filtering and denial records (X-504.AC02, X-504.AC03).
2
+ //
3
+ // `redactOutbound` is the structured counterpart of egress/redact.js: that module
4
+ // redacts prompt TEXT bound for a model endpoint, this one redacts a whole
5
+ // request (URL, headers, body) bound for a declared network destination. It
6
+ // reuses the same secret detector (`redactSecrets`), then adds what a request has
7
+ // that prose does not: credentials in URL userinfo, secret-named query
8
+ // parameters and header names, and secret-named keys in JSON and form bodies.
9
+ // Exact canary values (and their URL-encoded and base64 forms) are removed
10
+ // wherever they appear, which is what the canary fixtures assert.
11
+ //
12
+ // What it cannot do is look inside an encrypted tunnel. A CONNECT tunnel to a
13
+ // declared https destination is opaque to the proxy; the control for that path is
14
+ // that the task was never handed a secret to put in it (secret-free environment,
15
+ // protected paths, no secrets in arguments), and the capability report says
16
+ // `payloadFiltering: plaintext-http-only`.
17
+ import crypto from 'node:crypto';
18
+ import { redactSecretShapes } from '../mcp/redact.js';
19
+ import { scrubSecretText } from './secrets.js';
20
+ import { recordEgressCall } from '../egress/audit.js';
21
+ import { normalizeHost, classifyAddress } from './address.js';
22
+
23
+ const PLACEHOLDER = '[REDACTED-SECRET]';
24
+ const SECRET_NAME = /(?:^|[^a-z])(?:token|secret|passw(?:or)?d|api[-_]?key|apikey|auth(?:orization)?|credential|private[-_]?key|session|cookie|signature|sig|bearer)(?:[^a-z]|$)/i;
25
+ const MAX_DEPTH = 32;
26
+
27
+ export function isSecretName(name) {
28
+ return typeof name === 'string' && SECRET_NAME.test(name);
29
+ }
30
+
31
+ /** The forms in which a canary value could travel: raw, URL-encoded, base64 and its URL-safe variant. */
32
+ function canaryForms(canaries) {
33
+ const out = new Set();
34
+ for (const c of canaries || []) {
35
+ if (typeof c !== 'string' || c.length < 6) continue;
36
+ out.add(c);
37
+ out.add(encodeURIComponent(c));
38
+ const b64 = Buffer.from(c, 'utf8').toString('base64');
39
+ out.add(b64);
40
+ out.add(b64.replace(/=+$/, ''));
41
+ out.add(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''));
42
+ }
43
+ return [...out].filter((x) => x.length >= 6);
44
+ }
45
+
46
+ function scrubText(text, forms, stats) {
47
+ let out = String(text);
48
+ for (const f of forms) {
49
+ if (out.includes(f)) { out = out.split(f).join(PLACEHOLDER); stats.canary += 1; }
50
+ }
51
+ const r = scrubSecretText(out);
52
+ if (r.redactions > 0) { stats.secrets += r.redactions; out = r.text; }
53
+ return out;
54
+ }
55
+
56
+ function walk(value, forms, stats, depth = 0) {
57
+ if (depth > MAX_DEPTH) { stats.truncated = true; return PLACEHOLDER; }
58
+ if (typeof value === 'string') return scrubText(value, forms, stats);
59
+ if (Array.isArray(value)) return value.map((v) => walk(v, forms, stats, depth + 1));
60
+ if (value && typeof value === 'object') {
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(value)) {
63
+ const key = scrubText(k, forms, stats);
64
+ if (isSecretName(k) && (typeof v === 'string' || typeof v === 'number')) { out[key] = PLACEHOLDER; stats.keys += 1; }
65
+ else out[key] = walk(v, forms, stats, depth + 1);
66
+ }
67
+ return out;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function scrubParams(params, forms, stats) {
73
+ const out = new URLSearchParams();
74
+ for (const [k, v] of params) {
75
+ if (isSecretName(k) && v !== '') { out.append(scrubText(k, forms, stats), PLACEHOLDER); stats.keys += 1; }
76
+ else out.append(scrubText(k, forms, stats), scrubText(v, forms, stats));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Filter a request before it leaves.
83
+ * @param {{url?: string, headers?: object, body?: string|Buffer|object, contentType?: string}} req
84
+ * @param {{canaries?: string[]}} [opts]
85
+ * @returns {{url: string|null, headers: object, body: string|null, redactions: number,
86
+ * categories: {userinfo:number, secrets:number, keys:number, canary:number}, uninspectable: boolean}}
87
+ * `uninspectable` is true for a binary body, which is returned untouched and
88
+ * must not be sent by a caller that requires filtering.
89
+ */
90
+ export function redactOutbound(req = {}, opts = {}) {
91
+ const forms = canaryForms(opts.canaries);
92
+ const stats = { secrets: 0, keys: 0, canary: 0, userinfo: 0, truncated: false };
93
+ let url = null;
94
+ if (typeof req.url === 'string') {
95
+ try {
96
+ const u = new URL(req.url);
97
+ if (u.username || u.password) { stats.userinfo += 1; u.username = ''; u.password = ''; }
98
+ u.search = scrubParams(u.searchParams, forms, stats).toString();
99
+ u.pathname = u.pathname.split('/').map((seg) => scrubText(decodeURIComponent(seg), forms, stats)).map(encodeURIComponent).join('/');
100
+ u.hash = '';
101
+ url = u.toString();
102
+ } catch {
103
+ url = scrubText(req.url.replace(/\/\/[^/@\s]*@/, '//'), forms, stats);
104
+ }
105
+ }
106
+ const headers = {};
107
+ for (const [name, raw] of Object.entries(req.headers || {})) {
108
+ const values = Array.isArray(raw) ? raw : [raw];
109
+ const cleaned = values.map((v) => {
110
+ if (isSecretName(name)) { stats.keys += 1; return PLACEHOLDER; }
111
+ return scrubText(String(v), forms, stats);
112
+ });
113
+ headers[name.toLowerCase()] = Array.isArray(raw) ? cleaned : cleaned[0];
114
+ }
115
+ let body = null;
116
+ let uninspectable = false;
117
+ if (req.body !== undefined && req.body !== null) {
118
+ const ct = String(req.contentType || headers['content-type'] || '').toLowerCase();
119
+ if (Buffer.isBuffer(req.body) && req.body.includes(0)) {
120
+ uninspectable = true; body = req.body;
121
+ } else if (typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
122
+ body = JSON.stringify(walk(req.body, forms, stats));
123
+ } else {
124
+ const text = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body);
125
+ if (ct.includes('json')) {
126
+ try { body = JSON.stringify(walk(JSON.parse(text), forms, stats)); } catch { body = scrubText(text, forms, stats); }
127
+ } else if (ct.includes('x-www-form-urlencoded')) {
128
+ body = scrubParams(new URLSearchParams(text), forms, stats).toString();
129
+ } else body = scrubText(text, forms, stats);
130
+ }
131
+ }
132
+ const categories = { userinfo: stats.userinfo, secrets: stats.secrets, keys: stats.keys, canary: stats.canary };
133
+ return {
134
+ url, headers, body, categories, uninspectable,
135
+ redactions: stats.userinfo + stats.secrets + stats.keys + stats.canary,
136
+ };
137
+ }
138
+
139
+ /** Log-safe text: canary values and secret shapes removed. Never used to build a decision reason. */
140
+ export function sanitizeLogText(text, canaries = []) {
141
+ let out = typeof text === 'string' ? text : JSON.stringify(text) ?? '';
142
+ for (const f of canaryForms(canaries)) out = out.split(f).join(PLACEHOLDER);
143
+ // Provider-shaped credentials only. The high-entropy-literal heuristic is for
144
+ // payloads, where over-redaction is acceptable; here it would rewrite ordinary
145
+ // quoted paths in a task's output.
146
+ return redactSecretShapes(out).text;
147
+ }
148
+
149
+ // ---------------------------------------------------------------- denial records
150
+
151
+ /** Class of a destination for the audit trail: an address class, or `hostname`. */
152
+ export function destinationClass(host) {
153
+ const h = normalizeHost(host);
154
+ if (!h.ok) return 'invalid';
155
+ if (h.kind === 'name') return h.host === 'localhost' || h.host.endsWith('.localhost') ? 'loopback' : 'hostname';
156
+ return classifyAddress(h.host);
157
+ }
158
+
159
+ /**
160
+ * What a denied egress leaves behind: the destination CLASS, a short digest of
161
+ * the host (a host name can itself carry data, so it is never stored), the port,
162
+ * the scheme and the policy code. No path, query, header or body, ever.
163
+ */
164
+ export function denialRecord({ taskId, host, port, scheme, code }) {
165
+ const h = normalizeHost(host);
166
+ const digest = crypto.createHash('sha256').update(h.ok ? h.host : String(host)).digest('hex').slice(0, 12);
167
+ return Object.freeze({
168
+ taskId: taskId ?? null, outcome: 'deny', code, destinationClass: destinationClass(host),
169
+ hostDigest: digest, port: Number.isInteger(port) ? port : null, scheme: scheme === 'http' || scheme === 'https' ? scheme : null,
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Append a denial to the existing tamper-evident egress audit chain
175
+ * (egress/audit.js). The chain records provider, purpose, outcome and reason, so
176
+ * the destination class is carried as the provider and the policy code as the
177
+ * reason; nothing else is passed in.
178
+ */
179
+ export function recordNetworkDenial(scanRoot, record) {
180
+ if (!scanRoot || !record) return;
181
+ recordEgressCall({
182
+ scanRoot,
183
+ decision: {
184
+ allowed: false, decision: 'deny', reason: record.code, provider: record.destinationClass,
185
+ policySource: 'capability-manifest', purpose: 'capability-network',
186
+ },
187
+ ctx: {},
188
+ });
189
+ }
@@ -0,0 +1,57 @@
1
+ // Path handling for capability decisions (X-502). The OS boundary is what
2
+ // actually blocks an access; these helpers only let a policy decision and a
3
+ // pre-flight check agree with it about what a path names, including `..`
4
+ // segments and symbolic links.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const MAX_PATH = 4096;
9
+
10
+ /**
11
+ * Lexical form of an absolute path: normalized, no trailing separator.
12
+ * `dotdot` reports whether the caller wrote a parent-directory segment, which
13
+ * is kept so a decision can say "traversal" rather than just "outside".
14
+ */
15
+ export function lexicalPath(p) {
16
+ if (typeof p !== 'string' || p.length === 0 || p.length > MAX_PATH || p.includes('\0')) return { ok: false };
17
+ if (!path.isAbsolute(p)) return { ok: false };
18
+ const dotdot = p.split(path.sep).includes('..');
19
+ let n = path.normalize(p);
20
+ if (n.length > 1 && n.endsWith(path.sep)) n = n.slice(0, -1);
21
+ return { ok: true, path: n, dotdot };
22
+ }
23
+
24
+ /**
25
+ * Canonical form: symbolic links resolved for the longest existing prefix, the
26
+ * not-yet-existing remainder appended lexically. This is what a write to a new
27
+ * file is judged by.
28
+ */
29
+ export function canonicalPath(p) {
30
+ const lex = lexicalPath(p);
31
+ if (!lex.ok) return null;
32
+ let head = lex.path;
33
+ const tail = [];
34
+ for (let i = 0; i < 4096; i++) {
35
+ try {
36
+ const real = fs.realpathSync(head);
37
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
38
+ } catch (e) {
39
+ if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') return null;
40
+ const parent = path.dirname(head);
41
+ if (parent === head) return null;
42
+ tail.push(path.basename(head));
43
+ head = parent;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** `child` equals `root` or lies beneath it. Both must already be normalized. */
50
+ export function isWithin(child, root) {
51
+ return child === root || child.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
52
+ }
53
+
54
+ /** True when two normalized paths overlap in either direction. */
55
+ export function overlaps(a, b) {
56
+ return isWithin(a, b) || isWithin(b, a);
57
+ }