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