@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.
- package/CHANGELOG.md +26 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- 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
|
+
}
|