@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,211 @@
1
+ // Active probes for the controls capability enforcement depends on (X-502, X-503,
2
+ // X-504), and the platform status that follows from them.
3
+ //
4
+ // The sandbox already probes write confinement, read denial of named host paths,
5
+ // environment scrubbing, the no-network default, tree termination and the
6
+ // file-size limit (sandbox/control-probes.js). Capability enforcement needs three
7
+ // more, each established the same way: an attack through the real backend that
8
+ // must fail, paired with a positive control that must succeed, so a probe that
9
+ // cannot succeed cannot pass for a working control.
10
+ //
11
+ // fs-read-confinement a canary outside every declared root is unreadable by
12
+ // the task, by a descendant, through a symbolic link
13
+ // planted inside a readable root and through `..`; the
14
+ // same canary IS readable once its directory is declared
15
+ // fs-multi-root-write several declared write roots are writable, a read-only
16
+ // root and an undeclared directory are not
17
+ // network-mediation a declared destination is reachable through the
18
+ // mediation proxy, an undeclared one is refused by the
19
+ // proxy, and a direct socket to either is refused by the
20
+ // operating system
21
+ //
22
+ // Nothing here asserts an outcome on a platform it did not run on. On the
23
+ // namespace backend (Linux) the new controls are `unsupported`, because that
24
+ // backend does not implement them, and the inherited probes run for real where a
25
+ // Linux host exists. No state is ever inherited from another platform.
26
+ import fs from 'node:fs';
27
+ import http from 'node:http';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import crypto from 'node:crypto';
31
+ import { detectBackend } from '../sandbox/capabilities.js';
32
+ import { runConfinedSupervised } from '../sandbox/supervise.js';
33
+ import { probeControls, unmetControls } from '../sandbox/control-probes.js';
34
+ import { digestOf } from '../posture/assurance/identity.js';
35
+ import { FEATURES } from '../posture/assurance/config.js';
36
+ import { bindManifest } from './manifest.js';
37
+ import { startMediationProxy } from './proxy.js';
38
+
39
+ export const CAPABILITY_CONTROLS = Object.freeze(['fs-read-confinement', 'fs-multi-root-write', 'network-mediation']);
40
+
41
+ const NODE = process.execPath;
42
+ const ZERO_REV = '0'.repeat(40);
43
+ const proved = (evidence) => ({ state: 'proved', evidence });
44
+ const notProved = (reason) => ({ state: 'not-proved', reason });
45
+ const unsupported = (reason) => ({ state: 'unsupported', reason });
46
+
47
+ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
48
+ function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
49
+ const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
50
+
51
+ async function probeFsRead(backend) {
52
+ if (backend !== 'userspace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
53
+ const root = mk('agsec-cap-r-'); const ro = mk('agsec-cap-ro-'); const outside = mk('agsec-cap-out-');
54
+ try {
55
+ const okTok = token('READABLE'); const secTok = token('SEALED');
56
+ fs.writeFileSync(path.join(ro, 'r.txt'), okTok);
57
+ fs.writeFileSync(path.join(outside, 's.txt'), secTok);
58
+ fs.symlinkSync(path.join(outside, 's.txt'), path.join(ro, 'link'));
59
+ const run = (script, readRoots) => runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
60
+ const pos = await run(`cat '${ro}/r.txt'`, [ro]);
61
+ if (!pos.stdout.includes(okTok)) return notProved(`positive control failed: a file inside a declared root was not readable (${pos.status})`);
62
+ const open = await run(`cat '${outside}/s.txt'`, [ro, outside]);
63
+ if (!open.stdout.includes(secTok)) return notProved('positive control failed: the canary was not readable even when its directory was declared');
64
+ const attempts = {
65
+ direct: `cat '${outside}/s.txt'`,
66
+ 'symbolic link': `cat '${ro}/link'`,
67
+ traversal: `cat '${ro}/../${path.basename(outside)}/s.txt'`,
68
+ descendant: `sh -c "cat '${outside}/s.txt'"; ( cat '${outside}/s.txt' ) 2>&1`,
69
+ };
70
+ for (const [name, script] of Object.entries(attempts)) {
71
+ const r = await run(`${script}; true`, [ro]);
72
+ if (r.stdout.includes(secTok)) return notProved(`the canary outside every declared root was readable (${name})`);
73
+ }
74
+ return proved('declared roots readable; canary unreadable directly, through a link, through .., and from a descendant; readable once declared');
75
+ } finally { rm(root); rm(ro); rm(outside); }
76
+ }
77
+
78
+ async function probeMultiWrite(backend) {
79
+ if (backend !== 'userspace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
80
+ const root = mk('agsec-cap-w-'); const w1 = mk('agsec-cap-w1-'); const w2 = mk('agsec-cap-w2-');
81
+ const ro = mk('agsec-cap-wro-'); const outside = mk('agsec-cap-wout-');
82
+ try {
83
+ const script = [`echo a > '${w1}/f'`, `echo b > '${w2}/f'`, `echo c > '${ro}/f'`, `echo d > '${outside}/f'`, 'true'].join('; ');
84
+ await runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
85
+ if (!fs.existsSync(path.join(w1, 'f')) || !fs.existsSync(path.join(w2, 'f'))) return notProved('positive control failed: a declared write root was not writable');
86
+ if (fs.existsSync(path.join(ro, 'f'))) return notProved('a root declared read-only was writable');
87
+ if (fs.existsSync(path.join(outside, 'f'))) return notProved('an undeclared directory was writable');
88
+ return proved('both declared write roots writable; the read-only root and an undeclared directory were not');
89
+ } finally { rm(root); rm(w1); rm(w2); rm(ro); rm(outside); }
90
+ }
91
+
92
+ function listener(label) {
93
+ const state = { connections: 0 };
94
+ const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(label); });
95
+ server.on('connection', () => { state.connections += 1; });
96
+ return new Promise((resolve, reject) => {
97
+ server.once('error', reject);
98
+ server.listen(0, '127.0.0.1', () => resolve({ server, state, port: server.address().port }));
99
+ });
100
+ }
101
+
102
+ async function probeNetworkMediation(backend) {
103
+ if (backend !== 'userspace') return unsupported(`mediated network access is not implemented on the ${backend} backend (its network namespace has no path to a proxy)`);
104
+ const root = mk('agsec-cap-n-');
105
+ let a; let b; let proxy;
106
+ try {
107
+ a = await listener('A'); b = await listener('B');
108
+ const built = bindManifest({
109
+ schema: 'agentic-security/capability-manifest', schemaVersion: '1.0.0', taskId: 'probe-network', repository: { revision: ZERO_REV }, policyVersion: 1,
110
+ network: [{ host: '127.0.0.1', port: a.port, schemes: ['http'] }],
111
+ });
112
+ if (!built.ok) return notProved('probe manifest was invalid');
113
+ proxy = await startMediationProxy({ bound: built.bound });
114
+ const script = `
115
+ const http=require('http'),net=require('net');
116
+ const via=(p)=>new Promise(r=>{const q=http.request({host:'127.0.0.1',port:${proxy.port},method:'GET',path:'http://127.0.0.1:'+p+'/',headers:{host:'127.0.0.1:'+p},agent:false},res=>{let b='';res.on('data',d=>b+=d);res.on('end',()=>r({s:res.statusCode,b}))});q.on('error',e=>r({e:e.code}));q.setTimeout(4000,()=>q.destroy());q.end()});
117
+ const direct=(p)=>new Promise(r=>{const s=net.connect(p,'127.0.0.1');s.on('connect',()=>{s.destroy();r({connected:true})});s.on('error',e=>r({e:e.code}));setTimeout(()=>r({e:'TIMEOUT'}),2500)});
118
+ (async()=>{console.log(JSON.stringify({pa:await via(${a.port}),pb:await via(${b.port}),da:await direct(${a.port}),db:await direct(${b.port})}))})();`;
119
+ const r = await runConfinedSupervised([NODE, '-e', script], {
120
+ root, readRoots: [NODE], networkProxyPort: proxy.port, timeoutMs: 15000, graceMs: 300,
121
+ });
122
+ let out;
123
+ try { out = JSON.parse(String(r.stdout).trim().split('\n').pop()); } catch { return notProved(`positive control failed: the probe client did not report (${r.status})`); }
124
+ if (!(out.pa && out.pa.s === 200 && out.pa.b === 'A')) return notProved('positive control failed: a declared destination was not reachable through the proxy');
125
+ if (!(out.pb && out.pb.s === 403)) return notProved('an undeclared destination was not refused by the proxy');
126
+ if (out.da?.connected || out.db?.connected) return notProved('a direct socket bypassed the proxy');
127
+ if (b.state.connections !== 0) return notProved('the undeclared listener saw a connection');
128
+ if (a.state.connections !== 1) return notProved(`the declared listener saw ${a.state.connections} connections; only the proxied request should have arrived`);
129
+ return proved('declared destination reachable only through the proxy; undeclared refused by the proxy; direct sockets refused by the operating system');
130
+ } finally {
131
+ if (proxy) await proxy.close();
132
+ for (const l of [a, b]) if (l) await new Promise((res) => l.server.close(() => res()));
133
+ rm(root);
134
+ }
135
+ }
136
+
137
+ const _cache = new Map();
138
+ export function resetCapabilityProbeCache() { _cache.clear(); }
139
+
140
+ async function measure(backend, force) {
141
+ const base = await probeControls({ force });
142
+ const controls = { ...base.controls };
143
+ if (backend === 'disabled') {
144
+ for (const c of CAPABILITY_CONTROLS) controls[c] = { state: 'unavailable', reason: 'no confinement backend works on this host' };
145
+ } else {
146
+ const run = async (name, fn) => {
147
+ try { controls[name] = await fn(); } catch (e) { controls[name] = notProved(`probe threw: ${String(e.message).split('\n')[0]}`); }
148
+ };
149
+ await run('fs-read-confinement', () => probeFsRead(backend));
150
+ await run('fs-multi-root-write', () => probeMultiWrite(backend));
151
+ await run('network-mediation', () => probeNetworkMediation(backend));
152
+ }
153
+ return controls;
154
+ }
155
+
156
+ /**
157
+ * Probe every control capability enforcement uses, on the active backend.
158
+ * @param {object} [o]
159
+ * @param {string} [o.force] backend override (same meaning as runConfined's)
160
+ * @param {object} [o.probes] per-control overrides (test seam). An override can only
161
+ * be used to make a control LESS proved in a test; a run
162
+ * still goes through the real backend.
163
+ */
164
+ export async function probeCapabilityControls({ force, probes = {} } = {}) {
165
+ const backend = detectBackend({ force });
166
+ if (!_cache.has(backend)) _cache.set(backend, await measure(backend, force));
167
+ const controls = { ..._cache.get(backend) };
168
+ // Overrides replace one control's result after the real measurement; the run
169
+ // itself still goes through the real backend.
170
+ for (const [name, fn] of Object.entries(probes)) controls[name] = await fn();
171
+ const states = Object.fromEntries(Object.entries(controls).sort().map(([k, v]) => [k, v.state]));
172
+ return {
173
+ platform: process.platform, backend, controls,
174
+ probeDigest: digestOf({ platform: process.platform, backend, states }),
175
+ };
176
+ }
177
+
178
+ /** Controls this manifest actually depends on; a control it never uses cannot block it. */
179
+ export function requiredControlsFor(manifest) {
180
+ const req = ['write-confinement', 'read-denial', 'fs-read-confinement', 'fs-multi-root-write', 'env-scrub', 'tree-termination', 'file-size-limit', 'network'];
181
+ if (manifest.network.length) req.push('network-mediation');
182
+ return req;
183
+ }
184
+
185
+ /**
186
+ * Whether this platform and backend are one the product ADVERTISES for enforced
187
+ * mode. Taken from the feature table in the assurance config (Linux only), and
188
+ * the only advertised backend is the kernel-namespace one. The macOS userspace
189
+ * backend can be proved on a host by the probes above, and is still not
190
+ * advertised: it is development-host evidence, not an enforced backend.
191
+ */
192
+ export function isAdvertisedBackend(backend, platform = process.platform) {
193
+ return FEATURES['capability-enforcement'].platforms.includes(platform) && backend === 'namespace';
194
+ }
195
+
196
+ /** Standing platform statements. Nothing here is measured; it states what has and has not been evidenced. */
197
+ export function platformStatements() {
198
+ return Object.freeze({
199
+ linux: {
200
+ backend: 'namespace', status: 'unverified',
201
+ note: 'advertised for enforced mode, but the control probes in this module have not run on a Linux host from this workspace. Several controls are not implemented on the namespace backend (read confinement to declared roots, several write roots, mediated network, supervised tree termination), so enforced mode is blocked there until they are implemented and the sandbox-linux job shows them proved. Process-count caps are never claimed.',
202
+ },
203
+ darwin: {
204
+ backend: 'userspace', status: 'host-proved-not-advertised',
205
+ note: 'the active probes can prove the controls on a macOS host, and tasks can run there for development only when the caller opts in; macOS is not an advertised enforced backend.',
206
+ },
207
+ win32: { backend: null, status: 'unsupported', note: 'no isolation backend exists on Windows.' },
208
+ });
209
+ }
210
+
211
+ export { unmetControls };
@@ -0,0 +1,182 @@
1
+ // The mediated network boundary (X-504.AC01, X-504.AC03).
2
+ //
3
+ // The runner starts one of these per task that declares network destinations and
4
+ // opens exactly its loopback port in the sandbox profile. Everything else is
5
+ // closed by the operating system: DNS, every other loopback port, every remote
6
+ // address, every unix socket. A child, a descendant or an interpreter that
7
+ // ignores the proxy settings and opens a socket itself is refused by the kernel,
8
+ // not by this file; this file decides what the ONE open door lets through.
9
+ //
10
+ // Per request:
11
+ // 1. the destination is checked against the bound manifest (`decide`, kind
12
+ // 'network') before any name is resolved
13
+ // 2. the name is resolved ONCE, in this process (the sandbox cannot resolve),
14
+ // the resolved addresses are checked (a declared name that resolves to a
15
+ // loopback, private, link-local or metadata address is refused unless the
16
+ // entry says so; a pinned address set must match), and the connection goes to
17
+ // the address that was checked, so a name that changes between check and
18
+ // connect cannot redirect the connection
19
+ // 3. plaintext HTTP is buffered (bounded), filtered with `redactOutbound`, and
20
+ // forwarded; a binary body that cannot be inspected is refused
21
+ // 4. HTTPS is an opaque CONNECT tunnel, allowed only to a destination declared
22
+ // for the https scheme. Its payload is NOT inspected and the report says so.
23
+ // 5. a redirect is returned to the client unchanged and never followed here, so
24
+ // the next hop arrives as a new request and meets step 1 again
25
+ //
26
+ // A denial records the destination class, a digest of the host, the port and the
27
+ // policy code. It never records a path, header, body or the host name itself.
28
+ import dns from 'node:dns';
29
+ import http from 'node:http';
30
+ import net from 'node:net';
31
+ import { decide } from './decide.js';
32
+ import { redactOutbound, denialRecord, recordNetworkDenial } from './outbound.js';
33
+ import { normalizeHost } from './address.js';
34
+
35
+ const HOP_BY_HOP = new Set([
36
+ 'connection', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'keep-alive', 'te', 'trailer',
37
+ 'transfer-encoding', 'upgrade', 'content-length',
38
+ ]);
39
+ const MAX_RECORDS = 1000;
40
+
41
+ async function defaultResolve(host) {
42
+ const rows = await dns.promises.lookup(host, { all: true });
43
+ return rows.map((r) => r.address);
44
+ }
45
+
46
+ /**
47
+ * @param {object} o
48
+ * @param {{manifest: object, binding: object}} o.bound
49
+ * @param {string[]} [o.canaries]
50
+ * @param {(host:string)=>Promise<string[]>} [o.resolve] test seam for name resolution
51
+ * @param {number} [o.maxRequestBytes]
52
+ * @param {string} [o.scanRoot] when set, denials are appended to the egress audit chain
53
+ * @param {number} [o.connectTimeoutMs]
54
+ */
55
+ export async function startMediationProxy({
56
+ bound, canaries = [], resolve = defaultResolve, maxRequestBytes = 1024 * 1024, scanRoot = null, connectTimeoutMs = 8000,
57
+ }) {
58
+ const records = [];
59
+ const sockets = new Set();
60
+ const stats = { allowed: 0, denied: 0, redactions: 0, bytesForwarded: 0 };
61
+ const ctx = { binding: bound.binding, canaries };
62
+
63
+ const note = (outcome, code, host, port, scheme) => {
64
+ const rec = denialRecord({ taskId: bound.binding.taskId, host, port, scheme, code });
65
+ const entry = { ...rec, outcome };
66
+ if (records.length < MAX_RECORDS) records.push(entry);
67
+ if (outcome === 'deny') { stats.denied += 1; recordNetworkDenial(scanRoot, rec); } else stats.allowed += 1;
68
+ };
69
+
70
+ // Decide on the declared destination, resolve, decide again on the addresses.
71
+ async function authorize(rawHost, port, scheme) {
72
+ const first = decide(bound, { kind: 'network', host: rawHost, port, scheme }, ctx);
73
+ if (first.decision !== 'allow') return { ok: false, code: first.code };
74
+ const h = normalizeHost(rawHost);
75
+ let addresses;
76
+ if (h.kind === 'name') {
77
+ try { addresses = await resolve(h.host); } catch { addresses = []; }
78
+ if (!Array.isArray(addresses) || !addresses.every((a) => net.isIP(a))) addresses = [];
79
+ } else addresses = [h.host];
80
+ const second = decide(bound, { kind: 'network', host: rawHost, port, scheme, resolvedAddresses: addresses }, ctx);
81
+ if (second.decision !== 'allow') return { ok: false, code: second.code };
82
+ return { ok: true, address: addresses[0], host: h.host };
83
+ }
84
+
85
+ function deny(res, code) {
86
+ if (res.headersSent) { res.destroy(); return; }
87
+ const body = JSON.stringify({ blocked: true, code });
88
+ res.writeHead(403, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), connection: 'close' });
89
+ res.end(body);
90
+ }
91
+
92
+ const server = http.createServer((req, res) => {
93
+ handle(req, res).catch(() => deny(res, 'invalid-manifest'));
94
+ });
95
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
96
+ server.keepAliveTimeout = 1000;
97
+
98
+ async function handle(req, res) {
99
+ let target;
100
+ try { target = new URL(req.url); } catch { target = null; }
101
+ if (!target || target.protocol !== 'http:') {
102
+ // An origin-form request is a client talking to the proxy as if it were a
103
+ // server; an https:// absolute URI is a client that skipped CONNECT.
104
+ note('deny', 'destination-not-declared', req.headers.host || '', 0, null);
105
+ return deny(res, 'destination-not-declared');
106
+ }
107
+ const port = target.port ? Number(target.port) : 80;
108
+ const auth = await authorize(target.hostname, port, 'http');
109
+ if (!auth.ok) { note('deny', auth.code, target.hostname, port, 'http'); return deny(res, auth.code); }
110
+
111
+ const chunks = []; let size = 0; let tooBig = false;
112
+ await new Promise((done) => {
113
+ req.on('data', (c) => { size += c.length; if (size > maxRequestBytes) { tooBig = true; req.destroy(); done(); } else chunks.push(c); });
114
+ req.on('end', done); req.on('error', done); req.on('close', done);
115
+ });
116
+ if (tooBig) { note('deny', 'payload-too-large', target.hostname, port, 'http'); return deny(res, 'payload-too-large'); }
117
+ const body = chunks.length ? Buffer.concat(chunks) : null;
118
+ const filtered = redactOutbound({ url: req.url, headers: req.headers, body, contentType: req.headers['content-type'] }, { canaries });
119
+ if (filtered.uninspectable) { note('deny', 'payload-uninspectable', target.hostname, port, 'http'); return deny(res, 'payload-uninspectable'); }
120
+ stats.redactions += filtered.redactions;
121
+
122
+ const out = {};
123
+ for (const [k, v] of Object.entries(filtered.headers)) if (!HOP_BY_HOP.has(k) && k !== 'host') out[k] = v;
124
+ out.host = target.port ? `${target.hostname}:${target.port}` : target.hostname;
125
+ out.connection = 'close';
126
+ if (filtered.body !== null) out['content-length'] = Buffer.byteLength(filtered.body);
127
+ const fu = new URL(filtered.url);
128
+ const upstream = http.request({
129
+ host: auth.address, port, method: req.method, path: `${fu.pathname}${fu.search}`, headers: out, setHost: false,
130
+ timeout: connectTimeoutMs, agent: false,
131
+ });
132
+ note('allow', 'allowed', target.hostname, port, 'http');
133
+ upstream.on('timeout', () => upstream.destroy(new Error('timeout')));
134
+ upstream.on('error', () => deny(res, 'destination-not-declared'));
135
+ upstream.on('response', (up) => {
136
+ const headers = {};
137
+ for (const [k, v] of Object.entries(up.headers)) if (!HOP_BY_HOP.has(k)) headers[k] = v;
138
+ headers.connection = 'close';
139
+ res.writeHead(up.statusCode || 502, headers);
140
+ up.on('data', (c) => { stats.bytesForwarded += c.length; });
141
+ up.pipe(res);
142
+ });
143
+ if (filtered.body !== null) upstream.write(filtered.body);
144
+ upstream.end();
145
+ }
146
+
147
+ server.on('connect', (req, client, head) => {
148
+ sockets.add(client); client.on('close', () => sockets.delete(client));
149
+ client.on('error', () => client.destroy());
150
+ (async () => {
151
+ const m = /^(\[[0-9a-fA-F:.]+\]|[^:\s]+):(\d{1,5})$/.exec(req.url || '');
152
+ if (!m) { note('deny', 'host-invalid', '', 0, 'https'); client.end('HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n'); return; }
153
+ const port = Number(m[2]);
154
+ const auth = await authorize(m[1], port, 'https');
155
+ if (!auth.ok) { note('deny', auth.code, m[1], port, 'https'); client.end('HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n'); return; }
156
+ const upstream = net.connect({ host: auth.address, port, timeout: connectTimeoutMs });
157
+ sockets.add(upstream); upstream.on('close', () => sockets.delete(upstream));
158
+ upstream.on('timeout', () => upstream.destroy());
159
+ upstream.on('error', () => { client.destroy(); });
160
+ client.on('close', () => upstream.destroy());
161
+ upstream.on('connect', () => {
162
+ note('allow', 'allowed', m[1], port, 'https');
163
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
164
+ if (head && head.length) upstream.write(head);
165
+ upstream.pipe(client); client.pipe(upstream);
166
+ });
167
+ })().catch(() => client.destroy());
168
+ });
169
+
170
+ await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
171
+ const port = server.address().port;
172
+ return {
173
+ port,
174
+ url: `http://127.0.0.1:${port}`,
175
+ records,
176
+ stats,
177
+ async close() {
178
+ for (const s of sockets) { try { s.destroy(); } catch { /* ignore */ } }
179
+ await new Promise((r) => server.close(() => r()));
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,67 @@
1
+ // Policy reason codes (X-501.AC03). A decision carries a code from this closed
2
+ // table and the fixed sentence beside it, never text built from the request. The
3
+ // request (a path, an argument, a host) can carry attacker-chosen content, and a
4
+ // decision is logged, put in receipts and shown to a worker, so nothing from the
5
+ // request may reach the reason. The only request-derived field of a decision is
6
+ // `subject`, which is sanitized and length-capped (`sanitizeSubject`).
7
+ import { scrubSecretText } from './secrets.js';
8
+
9
+ export const REASONS = Object.freeze({
10
+ allowed: 'the action is inside the declared capability scope',
11
+ 'no-grant': 'no capability of this kind is declared for the task',
12
+ 'binding-mismatch': 'the request is not bound to this task, repository revision and policy version',
13
+ 'unknown-action': 'the action kind is not recognized',
14
+ 'invalid-manifest': 'the capability manifest is invalid',
15
+ 'scope-expansion': 'the request would widen the capability scope granted by the parent task',
16
+ 'path-invalid': 'the path is not an absolute, well-formed path',
17
+ 'path-traversal': 'the path escapes the declared roots through parent-directory segments',
18
+ 'symlink-escape': 'the path resolves outside the declared roots through a symbolic link',
19
+ 'outside-roots': 'the path is outside every declared root',
20
+ 'protected-path': 'the path is protected from tasks (keys, sealed labels, evidence, credentials)',
21
+ 'executable-not-absolute': 'the executable must be an absolute path; names are not resolved through a search path',
22
+ 'executable-unresolvable': 'the executable does not exist or is not a regular executable file',
23
+ 'executable-in-writable-root': 'the executable lives in a writable root, so the task could replace it',
24
+ 'command-not-listed': 'the executable is not a declared command',
25
+ 'args-invalid': 'the argument list is not an array of plain strings within the size limits',
26
+ 'args-not-permitted': 'the arguments are not permitted for this declared command',
27
+ 'secret-in-argument': 'an argument contains secret material; arguments are visible to every process',
28
+ 'interpreter-blocked': 'the executable is a shell or language interpreter and is not declared as a scoped interpreter',
29
+ 'interpreter-args-unpinned': 'a scoped interpreter must be declared with its exact argument list',
30
+ 'host-invalid': 'the destination host is not a well-formed host name or address',
31
+ 'port-invalid': 'the destination port is not valid',
32
+ 'destination-not-declared': 'the destination is not a declared network destination',
33
+ 'port-not-declared': 'the destination host is declared but not on this port',
34
+ 'scheme-not-declared': 'the destination is declared but not for this scheme',
35
+ 'dns-private-address': 'the host name resolves to a loopback, private, link-local or metadata address that was not declared',
36
+ 'dns-changed': 'the host name resolves to addresses outside the pinned set',
37
+ 'payload-too-large': 'the outbound payload exceeds the mediated size limit',
38
+ 'payload-uninspectable': 'the outbound payload is binary and cannot be filtered for secrets',
39
+ 'tool-not-declared': 'the tool is not a declared tool action',
40
+ 'delegation-not-allowed': 'the task is not allowed to delegate',
41
+ 'delegation-depth': 'the delegation depth limit is reached',
42
+ 'resource-limit-exceeded': 'the request exceeds a declared resource limit',
43
+ 'tool-unclassified': 'the tool has no declared capability classification, so it cannot be checked and is refused',
44
+ 'identity-missing': 'no current task identity is bound to this server, so a mutating or externally communicating tool is refused',
45
+ 'identity-spoofed': 'the request names a task identity other than the one this server is bound to',
46
+ 'retry-limit': 'the same action was denied the permitted number of times in this policy version and stays blocked',
47
+ 'task-halted': 'the task reached its denial budget and is halted until an operator changes the policy',
48
+ });
49
+
50
+ export const REASON_CODES = Object.freeze(Object.keys(REASONS));
51
+
52
+ export function reasonText(code) {
53
+ return Object.prototype.hasOwnProperty.call(REASONS, code) ? REASONS[code] : REASONS['unknown-action'];
54
+ }
55
+
56
+ /**
57
+ * The one request-derived field of a decision. Control characters are removed,
58
+ * secret-shaped material is redacted and the length is capped, so the field is
59
+ * safe to log and to show to the worker that made the request.
60
+ */
61
+ export function sanitizeSubject(value, max = 160) {
62
+ let s = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
63
+ s = s.replace(/[\u0000-\u001f\u007f]/g, ' ');
64
+ try { s = scrubSecretText(s).text; } catch { s = '[unprintable]'; }
65
+ if (s.length > max) s = `${s.slice(0, max)}...`;
66
+ return s || '(empty)';
67
+ }