@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -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 +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- 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
package/dist/9560.index.js
CHANGED
|
@@ -2,6 +2,2091 @@ export const id = 9560;
|
|
|
2
2
|
export const ids = [9560,8218,9390,8752];
|
|
3
3
|
export const modules = {
|
|
4
4
|
|
|
5
|
+
/***/ 99405:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
e: () => (/* binding */ createToolGate)
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// UNUSED EXPORTS: featureEnabledByOperator
|
|
15
|
+
|
|
16
|
+
// EXTERNAL MODULE: ./src/posture/assurance/config.js
|
|
17
|
+
var config = __webpack_require__(90385);
|
|
18
|
+
// EXTERNAL MODULE: ./src/mcp/redact.js
|
|
19
|
+
var redact = __webpack_require__(83468);
|
|
20
|
+
// EXTERNAL MODULE: ./src/llm-validator/redact.js
|
|
21
|
+
var llm_validator_redact = __webpack_require__(65388);
|
|
22
|
+
;// CONCATENATED MODULE: ./src/capabilities/secrets.js
|
|
23
|
+
// One secret scrubber for the capability layer: provider-shaped credentials
|
|
24
|
+
// (mcp/redact.js, which is the list the scanner's own detector mirrors), then
|
|
25
|
+
// assignment, bearer, connection-string and high-entropy-literal shapes
|
|
26
|
+
// (llm-validator/redact.js). Pure. The count is of replacements made, so a
|
|
27
|
+
// caller can ask "did this text carry a secret" without keeping the secret.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when the text carries a provider-shaped credential or a quoted credential
|
|
33
|
+
* assignment. The high-entropy-literal heuristic is deliberately NOT used here:
|
|
34
|
+
* an argument list is mostly paths and scripts, and refusing every long quoted
|
|
35
|
+
* path would make the policy unusable. Scrubbing for output and logs (below) is
|
|
36
|
+
* allowed to be broader than refusing an argument.
|
|
37
|
+
*/
|
|
38
|
+
function detectSecretShapes(text) {
|
|
39
|
+
return typeof text === 'string' && (0,redact/* redactSecretShapes */.Kb)(text).redactions > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function secrets_scrubSecretText(text) {
|
|
43
|
+
if (typeof text !== 'string' || !text) return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
44
|
+
const a = (0,redact/* redactSecretShapes */.Kb)(text);
|
|
45
|
+
let b;
|
|
46
|
+
try { b = (0,llm_validator_redact/* redactSecrets */.f)(a.text); } catch { return { text: '[unprintable]', redactions: a.redactions + 1 }; }
|
|
47
|
+
return { text: b.text, redactions: a.redactions + b.redactions };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
;// CONCATENATED MODULE: ./src/capabilities/reasons.js
|
|
51
|
+
// Policy reason codes (X-501.AC03). A decision carries a code from this closed
|
|
52
|
+
// table and the fixed sentence beside it, never text built from the request. The
|
|
53
|
+
// request (a path, an argument, a host) can carry attacker-chosen content, and a
|
|
54
|
+
// decision is logged, put in receipts and shown to a worker, so nothing from the
|
|
55
|
+
// request may reach the reason. The only request-derived field of a decision is
|
|
56
|
+
// `subject`, which is sanitized and length-capped (`sanitizeSubject`).
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
const REASONS = Object.freeze({
|
|
60
|
+
allowed: 'the action is inside the declared capability scope',
|
|
61
|
+
'no-grant': 'no capability of this kind is declared for the task',
|
|
62
|
+
'binding-mismatch': 'the request is not bound to this task, repository revision and policy version',
|
|
63
|
+
'unknown-action': 'the action kind is not recognized',
|
|
64
|
+
'invalid-manifest': 'the capability manifest is invalid',
|
|
65
|
+
'scope-expansion': 'the request would widen the capability scope granted by the parent task',
|
|
66
|
+
'path-invalid': 'the path is not an absolute, well-formed path',
|
|
67
|
+
'path-traversal': 'the path escapes the declared roots through parent-directory segments',
|
|
68
|
+
'symlink-escape': 'the path resolves outside the declared roots through a symbolic link',
|
|
69
|
+
'outside-roots': 'the path is outside every declared root',
|
|
70
|
+
'protected-path': 'the path is protected from tasks (keys, sealed labels, evidence, credentials)',
|
|
71
|
+
'executable-not-absolute': 'the executable must be an absolute path; names are not resolved through a search path',
|
|
72
|
+
'executable-unresolvable': 'the executable does not exist or is not a regular executable file',
|
|
73
|
+
'executable-in-writable-root': 'the executable lives in a writable root, so the task could replace it',
|
|
74
|
+
'command-not-listed': 'the executable is not a declared command',
|
|
75
|
+
'args-invalid': 'the argument list is not an array of plain strings within the size limits',
|
|
76
|
+
'args-not-permitted': 'the arguments are not permitted for this declared command',
|
|
77
|
+
'secret-in-argument': 'an argument contains secret material; arguments are visible to every process',
|
|
78
|
+
'interpreter-blocked': 'the executable is a shell or language interpreter and is not declared as a scoped interpreter',
|
|
79
|
+
'interpreter-args-unpinned': 'a scoped interpreter must be declared with its exact argument list',
|
|
80
|
+
'host-invalid': 'the destination host is not a well-formed host name or address',
|
|
81
|
+
'port-invalid': 'the destination port is not valid',
|
|
82
|
+
'destination-not-declared': 'the destination is not a declared network destination',
|
|
83
|
+
'port-not-declared': 'the destination host is declared but not on this port',
|
|
84
|
+
'scheme-not-declared': 'the destination is declared but not for this scheme',
|
|
85
|
+
'dns-private-address': 'the host name resolves to a loopback, private, link-local or metadata address that was not declared',
|
|
86
|
+
'dns-changed': 'the host name resolves to addresses outside the pinned set',
|
|
87
|
+
'payload-too-large': 'the outbound payload exceeds the mediated size limit',
|
|
88
|
+
'payload-uninspectable': 'the outbound payload is binary and cannot be filtered for secrets',
|
|
89
|
+
'tool-not-declared': 'the tool is not a declared tool action',
|
|
90
|
+
'delegation-not-allowed': 'the task is not allowed to delegate',
|
|
91
|
+
'delegation-depth': 'the delegation depth limit is reached',
|
|
92
|
+
'resource-limit-exceeded': 'the request exceeds a declared resource limit',
|
|
93
|
+
'tool-unclassified': 'the tool has no declared capability classification, so it cannot be checked and is refused',
|
|
94
|
+
'identity-missing': 'no current task identity is bound to this server, so a mutating or externally communicating tool is refused',
|
|
95
|
+
'identity-spoofed': 'the request names a task identity other than the one this server is bound to',
|
|
96
|
+
'retry-limit': 'the same action was denied the permitted number of times in this policy version and stays blocked',
|
|
97
|
+
'task-halted': 'the task reached its denial budget and is halted until an operator changes the policy',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const REASON_CODES = Object.freeze(Object.keys(REASONS));
|
|
101
|
+
|
|
102
|
+
function reasonText(code) {
|
|
103
|
+
return Object.prototype.hasOwnProperty.call(REASONS, code) ? REASONS[code] : REASONS['unknown-action'];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The one request-derived field of a decision. Control characters are removed,
|
|
108
|
+
* secret-shaped material is redacted and the length is capped, so the field is
|
|
109
|
+
* safe to log and to show to the worker that made the request.
|
|
110
|
+
*/
|
|
111
|
+
function reasons_sanitizeSubject(value, max = 160) {
|
|
112
|
+
let s = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
|
|
113
|
+
s = s.replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
114
|
+
try { s = secrets_scrubSecretText(s).text; } catch { s = '[unprintable]'; }
|
|
115
|
+
if (s.length > max) s = `${s.slice(0, max)}...`;
|
|
116
|
+
return s || '(empty)';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
;// CONCATENATED MODULE: ./src/capabilities/tool-registry.js
|
|
120
|
+
// The capability classification of every registered MCP tool (X-505.AC01).
|
|
121
|
+
//
|
|
122
|
+
// A tool that writes, runs a project command or reaches the network must say so,
|
|
123
|
+
// and the gate (tool-gate.js) checks what it declares before the handler runs. A
|
|
124
|
+
// tool that is not in this table is refused when the gate is active
|
|
125
|
+
// (`tool-unclassified`), so adding a tool to the MCP registry without classifying
|
|
126
|
+
// it fails closed and fails a test (test/capabilities/tools.test.js compares this
|
|
127
|
+
// table with the registry in both directions).
|
|
128
|
+
//
|
|
129
|
+
// effect 'read' reads local state only
|
|
130
|
+
// 'mutating' writes project or agent state, or runs project code
|
|
131
|
+
// 'external' reaches, or may reach, the network through a package
|
|
132
|
+
// manager or another tool the server process runs
|
|
133
|
+
// requires the capability checks made before the handler:
|
|
134
|
+
// 'tool' the tool is a declared tool action
|
|
135
|
+
// 'write:sessionRoot' the session root is inside a declared write root
|
|
136
|
+
//
|
|
137
|
+
// What this table does NOT do: it does not route a tool's own file or network
|
|
138
|
+
// activity through the capability runner. A tool runs in the MCP server process;
|
|
139
|
+
// the decision is made at the tool boundary, so it is policy (`in-process-policy`),
|
|
140
|
+
// never enforced isolation, and the report says so.
|
|
141
|
+
const read = (note) => Object.freeze({ effect: 'read', requires: Object.freeze(['tool']), note });
|
|
142
|
+
const mutating = (note, extra = []) => Object.freeze({ effect: 'mutating', requires: Object.freeze(['tool', 'write:sessionRoot', ...extra]), note });
|
|
143
|
+
const external = (note, extra = []) => Object.freeze({ effect: 'external', requires: Object.freeze(['tool', ...extra]), note });
|
|
144
|
+
|
|
145
|
+
const TOOL_CAPABILITIES = Object.freeze({
|
|
146
|
+
scan_diff: read('scans files in memory'),
|
|
147
|
+
query_taint: read('reads the last verified scan'),
|
|
148
|
+
explain_finding: read('reads the last verified scan'),
|
|
149
|
+
find_rule_module: read('reads scanner source names'),
|
|
150
|
+
read_scratchpad: read('reads the agent scratchpad'),
|
|
151
|
+
read_agents_memory: read('reads the continual-learning file'),
|
|
152
|
+
lookup_cve: read('reads the local advisory caches'),
|
|
153
|
+
query_triage_memory: read('reads past triage decisions'),
|
|
154
|
+
query_findings_memory: read('reads accumulated scan memory'),
|
|
155
|
+
query_cache_telemetry: read('reads the session transcript statistics'),
|
|
156
|
+
synthesize_fix: read('returns a stored patch'),
|
|
157
|
+
dataflow_get_graph: read('reads the signed graph artifact'),
|
|
158
|
+
dataflow_get_node: read('reads the signed graph artifact'),
|
|
159
|
+
dataflow_get_edge: read('reads the signed graph artifact'),
|
|
160
|
+
dataflow_get_flow: read('reads the signed graph artifact'),
|
|
161
|
+
invariant_scenario_export: read('builds a read-only scenario export from a supplied contract, confined to the session root'),
|
|
162
|
+
portfolio_progress: read('builds a read-only progress view from a portfolio store, ledger and optional inputs, confined to the session root'),
|
|
163
|
+
apply_fix: mutating('writes verified patches into the project'),
|
|
164
|
+
verify_fix: mutating('runs the project linter and tests and appends fix metrics'),
|
|
165
|
+
append_scratchpad: mutating('writes under the agent scratchpad'),
|
|
166
|
+
append_agents_memory: mutating('appends to the continual-learning file'),
|
|
167
|
+
synthesize_sca_upgrade: external('runs a package manager dry-run, which may reach a registry'),
|
|
168
|
+
apply_sca_upgrade: external('runs the package manager and the project tests and rewrites manifests', ['write:sessionRoot']),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function toolCapabilityFor(name) {
|
|
172
|
+
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(TOOL_CAPABILITIES, name) ? TOOL_CAPABILITIES[name] : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Tools whose effect is not read-only: the ones that must always be checked. */
|
|
176
|
+
function isMutatingOrExternal(name) {
|
|
177
|
+
const c = toolCapabilityFor(name);
|
|
178
|
+
return c ? c.effect !== 'read' : true; // an unclassified tool is treated as the riskiest
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
182
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
183
|
+
// EXTERNAL MODULE: ./src/posture/evidence-bundle.js
|
|
184
|
+
var evidence_bundle = __webpack_require__(98317);
|
|
185
|
+
// EXTERNAL MODULE: ./src/sandbox/trust-domains.js
|
|
186
|
+
var trust_domains = __webpack_require__(38832);
|
|
187
|
+
// EXTERNAL MODULE: ./src/posture/assurance/identity.js
|
|
188
|
+
var identity = __webpack_require__(41877);
|
|
189
|
+
// EXTERNAL MODULE: external "node:path"
|
|
190
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
191
|
+
// EXTERNAL MODULE: ./src/posture/assurance/schema-kit.js
|
|
192
|
+
var schema_kit = __webpack_require__(53353);
|
|
193
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
194
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
195
|
+
;// CONCATENATED MODULE: ./src/capabilities/paths.js
|
|
196
|
+
// Path handling for capability decisions (X-502). The OS boundary is what
|
|
197
|
+
// actually blocks an access; these helpers only let a policy decision and a
|
|
198
|
+
// pre-flight check agree with it about what a path names, including `..`
|
|
199
|
+
// segments and symbolic links.
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
const MAX_PATH = 4096;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Lexical form of an absolute path: normalized, no trailing separator.
|
|
207
|
+
* `dotdot` reports whether the caller wrote a parent-directory segment, which
|
|
208
|
+
* is kept so a decision can say "traversal" rather than just "outside".
|
|
209
|
+
*/
|
|
210
|
+
function lexicalPath(p) {
|
|
211
|
+
if (typeof p !== 'string' || p.length === 0 || p.length > MAX_PATH || p.includes('\0')) return { ok: false };
|
|
212
|
+
if (!external_node_path_.isAbsolute(p)) return { ok: false };
|
|
213
|
+
const dotdot = p.split(external_node_path_.sep).includes('..');
|
|
214
|
+
let n = external_node_path_.normalize(p);
|
|
215
|
+
if (n.length > 1 && n.endsWith(external_node_path_.sep)) n = n.slice(0, -1);
|
|
216
|
+
return { ok: true, path: n, dotdot };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Canonical form: symbolic links resolved for the longest existing prefix, the
|
|
221
|
+
* not-yet-existing remainder appended lexically. This is what a write to a new
|
|
222
|
+
* file is judged by.
|
|
223
|
+
*/
|
|
224
|
+
function canonicalPath(p) {
|
|
225
|
+
const lex = lexicalPath(p);
|
|
226
|
+
if (!lex.ok) return null;
|
|
227
|
+
let head = lex.path;
|
|
228
|
+
const tail = [];
|
|
229
|
+
for (let i = 0; i < 4096; i++) {
|
|
230
|
+
try {
|
|
231
|
+
const real = external_node_fs_.realpathSync(head);
|
|
232
|
+
return tail.length ? external_node_path_.join(real, ...tail.reverse()) : real;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') return null;
|
|
235
|
+
const parent = external_node_path_.dirname(head);
|
|
236
|
+
if (parent === head) return null;
|
|
237
|
+
tail.push(external_node_path_.basename(head));
|
|
238
|
+
head = parent;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `child` equals `root` or lies beneath it. Both must already be normalized. */
|
|
245
|
+
function isWithin(child, root) {
|
|
246
|
+
return child === root || child.startsWith(root.endsWith(external_node_path_.sep) ? root : root + external_node_path_.sep);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True when two normalized paths overlap in either direction. */
|
|
250
|
+
function overlaps(a, b) {
|
|
251
|
+
return isWithin(a, b) || isWithin(b, a);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// EXTERNAL MODULE: external "node:net"
|
|
255
|
+
var external_node_net_ = __webpack_require__(77030);
|
|
256
|
+
;// CONCATENATED MODULE: ./src/capabilities/address.js
|
|
257
|
+
// Host and address helpers for network policy (X-504). Pure.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Canonical host: lower case, one trailing dot removed, IPv6 brackets removed.
|
|
264
|
+
* Returns `{ ok, host, kind }` with kind `ip4`, `ip6` or `name`. Numeric forms
|
|
265
|
+
* that are not a strict dotted quad (`2130706433`, `0x7f.1`, `127.1`) are
|
|
266
|
+
* rejected, because different parsers read them as different addresses and a
|
|
267
|
+
* policy must not be decided on a reading the connecting stack may not share.
|
|
268
|
+
*/
|
|
269
|
+
function address_normalizeHost(raw) {
|
|
270
|
+
if (typeof raw !== 'string') return { ok: false };
|
|
271
|
+
let h = raw.trim().toLowerCase();
|
|
272
|
+
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
|
|
273
|
+
if (h.endsWith('.') && h.length > 1) h = h.slice(0, -1);
|
|
274
|
+
if (!h || h.length > 253 || /[^\x21-\x7e]/.test(h)) return { ok: false };
|
|
275
|
+
const family = external_node_net_.isIP(h);
|
|
276
|
+
if (family === 4) return { ok: true, host: h, kind: 'ip4' };
|
|
277
|
+
if (family === 6) return { ok: true, host: h, kind: 'ip6' };
|
|
278
|
+
const labels = h.split('.');
|
|
279
|
+
// A top-level label is never numeric; one that is (or starts 0x) is an address in disguise.
|
|
280
|
+
if (/^(?:0x[0-9a-f]*|[0-9]+)$/.test(labels[labels.length - 1])) return { ok: false };
|
|
281
|
+
if (!labels.every((l) => LABEL.test(l))) return { ok: false };
|
|
282
|
+
return { ok: true, host: h, kind: 'name' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function v4Parts(ip) { return ip.split('.').map(Number); }
|
|
286
|
+
|
|
287
|
+
function classifyV4(ip) {
|
|
288
|
+
const [a, b] = v4Parts(ip);
|
|
289
|
+
if (ip === '169.254.169.254') return 'metadata';
|
|
290
|
+
if (a === 127) return 'loopback';
|
|
291
|
+
if (a === 0) return 'unspecified';
|
|
292
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)) return 'private';
|
|
293
|
+
if (a === 169 && b === 254) return 'link-local';
|
|
294
|
+
if (a >= 224 && a <= 239) return 'multicast';
|
|
295
|
+
if (a >= 240) return 'reserved';
|
|
296
|
+
return 'public';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Class of an address: loopback, private, link-local, metadata, unspecified,
|
|
301
|
+
* multicast, reserved or public. IPv4-mapped IPv6 is judged as the IPv4
|
|
302
|
+
* address it carries. Anything that is not an address is `invalid`.
|
|
303
|
+
*/
|
|
304
|
+
function address_classifyAddress(ip) {
|
|
305
|
+
const family = external_node_net_.isIP(ip);
|
|
306
|
+
if (family === 4) return classifyV4(ip);
|
|
307
|
+
if (family !== 6) return 'invalid';
|
|
308
|
+
const l = ip.toLowerCase();
|
|
309
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l);
|
|
310
|
+
if (mapped) return classifyV4(mapped[1]);
|
|
311
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(l);
|
|
312
|
+
if (hex) {
|
|
313
|
+
const hi = parseInt(hex[1], 16); const lo = parseInt(hex[2], 16);
|
|
314
|
+
return classifyV4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
315
|
+
}
|
|
316
|
+
if (l === '::1') return 'loopback';
|
|
317
|
+
if (l === '::') return 'unspecified';
|
|
318
|
+
if (l.startsWith('fd00:ec2::254')) return 'metadata';
|
|
319
|
+
const first = parseInt(l.split(':')[0] || '0', 16);
|
|
320
|
+
if ((first & 0xfe00) === 0xfc00) return 'private';
|
|
321
|
+
if ((first & 0xffc0) === 0xfe80) return 'link-local';
|
|
322
|
+
if ((first & 0xff00) === 0xff00) return 'multicast';
|
|
323
|
+
return 'public';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Non-public classes a host name must not resolve to unless declared. */
|
|
327
|
+
function isNonPublicClass(cls) {
|
|
328
|
+
return cls !== 'public';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
;// CONCATENATED MODULE: ./src/capabilities/manifest.js
|
|
332
|
+
// Task capability manifests (X-501).
|
|
333
|
+
//
|
|
334
|
+
// A manifest is the complete statement of what one task may do: readable and
|
|
335
|
+
// writable roots, structured commands, network destinations, tool actions,
|
|
336
|
+
// resource limits and whether it may delegate. It is
|
|
337
|
+
//
|
|
338
|
+
// deny-by-default a section that is absent grants nothing; there is no
|
|
339
|
+
// wildcard that means "everything", and no field whose
|
|
340
|
+
// default is more permissive than its absence
|
|
341
|
+
// closed-world an unknown field, an unknown schema version, a relative
|
|
342
|
+
// path, a wildcard host with no domain behind it or a value
|
|
343
|
+
// of the wrong type invalidates the whole manifest; nothing
|
|
344
|
+
// is carried through, ignored or defaulted open
|
|
345
|
+
// bound to a task id, an exact repository revision and a policy
|
|
346
|
+
// version, carried as `binding` with the manifest digest, so
|
|
347
|
+
// a grant made for one task, revision or policy version
|
|
348
|
+
// cannot be replayed for another (decide.js checks it)
|
|
349
|
+
// monotonic `deriveChild` builds a child manifest only from a subset
|
|
350
|
+
// of the parent's grants; anything wider is `SCOPE_EXPANSION`
|
|
351
|
+
// and no child manifest is produced
|
|
352
|
+
//
|
|
353
|
+
// Pure data except for `deriveChild`, which compares canonical (symlink-resolved)
|
|
354
|
+
// paths so a link inside a granted root cannot widen a child.
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
const MANIFEST_SCHEMA = 'agentic-security/capability-manifest';
|
|
363
|
+
const MANIFEST_SCHEMA_VERSION = '1.0.0';
|
|
364
|
+
const ARG_MODES = Object.freeze(['exact', 'prefix', 'any']);
|
|
365
|
+
const NETWORK_SCHEMES = Object.freeze(['http', 'https']);
|
|
366
|
+
const MAX_DELEGATION_DEPTH = 8;
|
|
367
|
+
|
|
368
|
+
const MANIFEST_ALLOWED = [
|
|
369
|
+
'schema', 'schemaVersion', 'taskId', 'parentTaskId', 'repository', 'policyVersion', 'filesystem', 'commands',
|
|
370
|
+
'network', 'tools', 'resources', 'delegation', 'createdAt',
|
|
371
|
+
];
|
|
372
|
+
const MANIFEST_REQUIRED = ['schema', 'schemaVersion', 'taskId', 'repository', 'policyVersion'];
|
|
373
|
+
|
|
374
|
+
// Resource limits a manifest can carry. The first three come straight from the
|
|
375
|
+
// assurance config (same ranges), the file-size cap is enforced by the sandbox
|
|
376
|
+
// prelude, and the last two are CARRIED but never claimed enforced: see
|
|
377
|
+
// `RESOURCE_ENFORCEMENT` in runner.js.
|
|
378
|
+
const RESOURCE_RANGES = Object.freeze({
|
|
379
|
+
timeoutMs: { min: config/* LIMITS */.b1.timeoutMs.min, max: config/* LIMITS */.b1.timeoutMs.max },
|
|
380
|
+
maxOutputBytes: { min: config/* LIMITS */.b1.maxOutputBytes.min, max: config/* LIMITS */.b1.maxOutputBytes.max },
|
|
381
|
+
maxFileSizeKb: { min: 1, max: 1_048_576 },
|
|
382
|
+
maxProcesses: { min: 1, max: 4096 },
|
|
383
|
+
maxMemoryMiB: { min: config/* LIMITS */.b1.maxMemoryMiB.min, max: config/* LIMITS */.b1.maxMemoryMiB.max },
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const TOOL_NAME = /^[A-Za-z0-9_.:-]{1,80}$/;
|
|
387
|
+
const MAX_LIST = 256;
|
|
388
|
+
const MAX_ARGS = 256;
|
|
389
|
+
const MAX_ARG_LEN = 8192;
|
|
390
|
+
|
|
391
|
+
function checkPathList(ctx, where, list) {
|
|
392
|
+
if (list === undefined) return [];
|
|
393
|
+
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 []; }
|
|
394
|
+
const out = [];
|
|
395
|
+
list.forEach((p, i) => {
|
|
396
|
+
const lex = lexicalPath(p);
|
|
397
|
+
if (!lex.ok) { ctx.err('BAD_PATH', `${where}[${i}]`, 'must be an absolute path without NUL bytes'); return; }
|
|
398
|
+
if (lex.dotdot) { ctx.err('BAD_PATH', `${where}[${i}]`, 'must not contain parent-directory segments'); return; }
|
|
399
|
+
if (lex.path === external_node_path_.sep) { ctx.err('BAD_PATH', `${where}[${i}]`, 'the filesystem root cannot be granted'); return; }
|
|
400
|
+
out.push(lex.path);
|
|
401
|
+
});
|
|
402
|
+
return [...new Set(out)].sort();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function checkCommands(ctx, list) {
|
|
406
|
+
if (list === undefined) return [];
|
|
407
|
+
if (!Array.isArray(list) || list.length > MAX_LIST) { ctx.err('BAD_TYPE', 'commands', `must be an array of at most ${MAX_LIST} entries`); return []; }
|
|
408
|
+
const out = [];
|
|
409
|
+
list.forEach((c, i) => {
|
|
410
|
+
const w = `commands[${i}]`;
|
|
411
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(c)) { ctx.err('BAD_TYPE', w, 'must be an object'); return; }
|
|
412
|
+
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');
|
|
413
|
+
const lex = lexicalPath(c.executable);
|
|
414
|
+
if (!lex.ok || lex.dotdot) { ctx.err('BAD_PATH', `${w}.executable`, 'must be an absolute path without parent-directory segments'); return; }
|
|
415
|
+
let args = { mode: 'exact', values: [] };
|
|
416
|
+
if (c.args !== undefined) {
|
|
417
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(c.args)) { ctx.err('BAD_TYPE', `${w}.args`, 'must be an object {mode, values}'); return; }
|
|
418
|
+
for (const k of Object.keys(c.args)) if (!['mode', 'values'].includes(k)) ctx.err('UNKNOWN_FIELD', `${w}.args.${k}`, 'not part of args');
|
|
419
|
+
if (!ARG_MODES.includes(c.args.mode)) { ctx.err('UNKNOWN_ENUM', `${w}.args.mode`, `must be one of: ${ARG_MODES.join(', ')}`); return; }
|
|
420
|
+
const values = c.args.values === undefined ? [] : c.args.values;
|
|
421
|
+
const okValues = Array.isArray(values) && values.length <= MAX_ARGS
|
|
422
|
+
&& values.every((v) => typeof v === 'string' && v.length <= MAX_ARG_LEN && !v.includes('\0'));
|
|
423
|
+
if (!okValues) { ctx.err('BAD_TYPE', `${w}.args.values`, 'must be an array of plain strings within the size limits'); return; }
|
|
424
|
+
if (c.args.mode === 'any' && values.length) { ctx.err('RULE_VIOLATION', `${w}.args.values`, `mode 'any' takes no values`); return; }
|
|
425
|
+
args = { mode: c.args.mode, values: [...values] };
|
|
426
|
+
}
|
|
427
|
+
let interpreter = null;
|
|
428
|
+
if (c.interpreter !== undefined && c.interpreter !== null) {
|
|
429
|
+
if (c.interpreter !== 'scoped') { ctx.err('UNKNOWN_ENUM', `${w}.interpreter`, `must be 'scoped' when present`); return; }
|
|
430
|
+
if (args.mode !== 'exact') { ctx.err('RULE_VIOLATION', `${w}.interpreter`, `a scoped interpreter must pin its exact arguments (mode 'exact')`); return; }
|
|
431
|
+
interpreter = 'scoped';
|
|
432
|
+
}
|
|
433
|
+
out.push({ executable: lex.path, args, interpreter });
|
|
434
|
+
});
|
|
435
|
+
const key = (c) => `${c.executable}\0${c.args.mode}\0${JSON.stringify(c.args.values)}\0${c.interpreter}`;
|
|
436
|
+
const seen = new Map(out.map((c) => [key(c), c]));
|
|
437
|
+
return [...seen.values()].sort((a, b) => (key(a) < key(b) ? -1 : 1));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function checkNetwork(ctx, list) {
|
|
441
|
+
if (list === undefined) return [];
|
|
442
|
+
if (!Array.isArray(list) || list.length > MAX_LIST) { ctx.err('BAD_TYPE', 'network', `must be an array of at most ${MAX_LIST} entries`); return []; }
|
|
443
|
+
const out = [];
|
|
444
|
+
list.forEach((d, i) => {
|
|
445
|
+
const w = `network[${i}]`;
|
|
446
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(d)) { ctx.err('BAD_TYPE', w, 'must be an object'); return; }
|
|
447
|
+
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');
|
|
448
|
+
if (typeof d.host !== 'string') { ctx.err('BAD_TYPE', `${w}.host`, 'must be a string'); return; }
|
|
449
|
+
let wildcard = false;
|
|
450
|
+
let hostText = d.host;
|
|
451
|
+
if (hostText.startsWith('*.')) { wildcard = true; hostText = hostText.slice(2); }
|
|
452
|
+
const h = address_normalizeHost(hostText);
|
|
453
|
+
if (!h.ok) { ctx.err('BAD_TYPE', `${w}.host`, 'must be a host name, an IP address or a *.domain wildcard'); return; }
|
|
454
|
+
if (wildcard && (h.kind !== 'name' || h.host.split('.').length < 2)) {
|
|
455
|
+
ctx.err('RULE_VIOLATION', `${w}.host`, 'a wildcard needs a registrable domain behind it (*.example.com), never a bare suffix or an address');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
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; }
|
|
459
|
+
const schemes = d.schemes === undefined ? ['https'] : d.schemes;
|
|
460
|
+
if (!Array.isArray(schemes) || !schemes.length || !schemes.every((s) => NETWORK_SCHEMES.includes(s))) {
|
|
461
|
+
ctx.err('UNKNOWN_ENUM', `${w}.schemes`, `must be a non-empty subset of: ${NETWORK_SCHEMES.join(', ')}`); return;
|
|
462
|
+
}
|
|
463
|
+
if (d.allowPrivateResolution !== undefined && typeof d.allowPrivateResolution !== 'boolean') { ctx.err('BAD_TYPE', `${w}.allowPrivateResolution`, 'must be a boolean'); return; }
|
|
464
|
+
let addresses = null;
|
|
465
|
+
if (d.addresses !== undefined && d.addresses !== null) {
|
|
466
|
+
const ok = Array.isArray(d.addresses) && d.addresses.length > 0 && d.addresses.length <= 32
|
|
467
|
+
&& d.addresses.every((a) => { const n = address_normalizeHost(a); return n.ok && n.kind !== 'name'; });
|
|
468
|
+
if (!ok) { ctx.err('BAD_TYPE', `${w}.addresses`, 'must be a non-empty array of IP addresses'); return; }
|
|
469
|
+
addresses = [...new Set(d.addresses.map((a) => address_normalizeHost(a).host))].sort();
|
|
470
|
+
}
|
|
471
|
+
out.push({
|
|
472
|
+
host: `${wildcard ? '*.' : ''}${h.host}`, port: d.port, schemes: [...new Set(schemes)].sort(),
|
|
473
|
+
allowPrivateResolution: d.allowPrivateResolution === true, addresses,
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
const key = (n) => JSON.stringify(n);
|
|
477
|
+
const seen = new Map(out.map((n) => [key(n), n]));
|
|
478
|
+
return [...seen.values()].sort((a, b) => (key(a) < key(b) ? -1 : 1));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function checkResources(ctx, r) {
|
|
482
|
+
if (r === undefined) return {};
|
|
483
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(r)) { ctx.err('BAD_TYPE', 'resources', 'must be an object'); return {}; }
|
|
484
|
+
const out = {};
|
|
485
|
+
for (const [k, v] of Object.entries(r)) {
|
|
486
|
+
const range = RESOURCE_RANGES[k];
|
|
487
|
+
if (!range) { ctx.err('UNKNOWN_FIELD', `resources.${k}`, 'not a known resource limit'); continue; }
|
|
488
|
+
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; }
|
|
489
|
+
out[k] = v;
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function checkDelegation(ctx, d) {
|
|
495
|
+
if (d === undefined) return { allow: false, maxDepth: 0 };
|
|
496
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(d)) { ctx.err('BAD_TYPE', 'delegation', 'must be an object'); return { allow: false, maxDepth: 0 }; }
|
|
497
|
+
for (const k of Object.keys(d)) if (!['allow', 'maxDepth'].includes(k)) ctx.err('UNKNOWN_FIELD', `delegation.${k}`, 'not part of delegation');
|
|
498
|
+
if (typeof d.allow !== 'boolean') { ctx.err('BAD_TYPE', 'delegation.allow', 'must be a boolean'); return { allow: false, maxDepth: 0 }; }
|
|
499
|
+
const maxDepth = d.maxDepth === undefined ? (d.allow ? 1 : 0) : d.maxDepth;
|
|
500
|
+
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 }; }
|
|
501
|
+
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 }; }
|
|
502
|
+
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 }; }
|
|
503
|
+
return { allow: d.allow, maxDepth };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Validate and normalize a manifest. Returns `{ ok, errors, manifest }`;
|
|
508
|
+
* `manifest` is the frozen normalized form and is present only when `ok`.
|
|
509
|
+
* Never throws.
|
|
510
|
+
*/
|
|
511
|
+
function manifest_validateManifest(m) {
|
|
512
|
+
const g = (0,schema_kit/* guardObject */.NW)(m);
|
|
513
|
+
const ctx = g.ctx;
|
|
514
|
+
if (!g.ok) return { ...(0,schema_kit/* result */.Ke)(ctx), manifest: null };
|
|
515
|
+
if (!(0,schema_kit/* checkHeader */.eb)(ctx, m, MANIFEST_SCHEMA)) return { ...(0,schema_kit/* result */.Ke)(ctx), manifest: null };
|
|
516
|
+
(0,schema_kit/* checkFields */.oA)(ctx, m, MANIFEST_ALLOWED, MANIFEST_REQUIRED);
|
|
517
|
+
(0,schema_kit/* checkString */.Em)(ctx, 'taskId', m.taskId);
|
|
518
|
+
if (m.parentTaskId !== undefined && m.parentTaskId !== null) (0,schema_kit/* checkString */.Em)(ctx, 'parentTaskId', m.parentTaskId);
|
|
519
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(m.repository)) ctx.err('BAD_TYPE', 'repository', 'must be an object {revision}');
|
|
520
|
+
else {
|
|
521
|
+
for (const k of Object.keys(m.repository)) if (k !== 'revision') ctx.err('UNKNOWN_FIELD', `repository.${k}`, 'not part of repository');
|
|
522
|
+
(0,schema_kit/* checkCommit */.su)(ctx, 'repository.revision', m.repository.revision ?? null, { nullable: false });
|
|
523
|
+
}
|
|
524
|
+
if (!Number.isInteger(m.policyVersion) || m.policyVersion < 1) ctx.err('BAD_TYPE', 'policyVersion', 'must be a positive integer');
|
|
525
|
+
|
|
526
|
+
let fsRead = []; let fsWrite = [];
|
|
527
|
+
if (m.filesystem !== undefined) {
|
|
528
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(m.filesystem)) ctx.err('BAD_TYPE', 'filesystem', 'must be an object {read, write}');
|
|
529
|
+
else {
|
|
530
|
+
for (const k of Object.keys(m.filesystem)) if (!['read', 'write'].includes(k)) ctx.err('UNKNOWN_FIELD', `filesystem.${k}`, 'not part of filesystem');
|
|
531
|
+
fsRead = checkPathList(ctx, 'filesystem.read', m.filesystem.read);
|
|
532
|
+
fsWrite = checkPathList(ctx, 'filesystem.write', m.filesystem.write);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const commands = checkCommands(ctx, m.commands);
|
|
536
|
+
const network = checkNetwork(ctx, m.network);
|
|
537
|
+
let tools = [];
|
|
538
|
+
if (m.tools !== undefined) {
|
|
539
|
+
if (!Array.isArray(m.tools) || m.tools.length > MAX_LIST || !m.tools.every((t) => typeof t === 'string' && TOOL_NAME.test(t))) {
|
|
540
|
+
ctx.err('BAD_TYPE', 'tools', `must be an array of tool names (${TOOL_NAME})`);
|
|
541
|
+
} else tools = [...new Set(m.tools)].sort();
|
|
542
|
+
}
|
|
543
|
+
const resources = checkResources(ctx, m.resources);
|
|
544
|
+
const delegation = checkDelegation(ctx, m.delegation);
|
|
545
|
+
if (ctx.errors.length) return { ...(0,schema_kit/* result */.Ke)(ctx), manifest: null };
|
|
546
|
+
|
|
547
|
+
const manifest = {
|
|
548
|
+
schema: MANIFEST_SCHEMA, schemaVersion: m.schemaVersion, taskId: m.taskId,
|
|
549
|
+
parentTaskId: m.parentTaskId ?? null,
|
|
550
|
+
repository: { revision: m.repository.revision }, policyVersion: m.policyVersion,
|
|
551
|
+
filesystem: { read: fsRead, write: fsWrite }, commands, network, tools, resources, delegation,
|
|
552
|
+
};
|
|
553
|
+
return { ok: true, errors: [], manifest: deepFreeze(manifest) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function deepFreeze(o) {
|
|
557
|
+
if (o && typeof o === 'object' && !Object.isFrozen(o)) {
|
|
558
|
+
Object.freeze(o);
|
|
559
|
+
for (const v of Object.values(o)) deepFreeze(v);
|
|
560
|
+
}
|
|
561
|
+
return o;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Digest of the normalized manifest: the policy identity a receipt binds to. */
|
|
565
|
+
function manifestDigest(manifest) {
|
|
566
|
+
return digestOf(manifest);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Validate and bind. The returned object is what every decision and the runner
|
|
571
|
+
* consume: the normalized manifest plus the binding it is valid for.
|
|
572
|
+
*/
|
|
573
|
+
function manifest_bindManifest(m) {
|
|
574
|
+
const v = manifest_validateManifest(m);
|
|
575
|
+
if (!v.ok) return { ok: false, errors: v.errors, bound: null };
|
|
576
|
+
const bound = {
|
|
577
|
+
manifest: v.manifest,
|
|
578
|
+
binding: Object.freeze({
|
|
579
|
+
taskId: v.manifest.taskId, revision: v.manifest.repository.revision,
|
|
580
|
+
policyVersion: v.manifest.policyVersion, digest: manifestDigest(v.manifest),
|
|
581
|
+
}),
|
|
582
|
+
};
|
|
583
|
+
return { ok: true, errors: [], bound: Object.freeze(bound) };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---------------------------------------------------------------- inheritance
|
|
587
|
+
|
|
588
|
+
function canon(p) { return canonicalPath(p) ?? p; }
|
|
589
|
+
|
|
590
|
+
function rootCovered(child, parentRoots) {
|
|
591
|
+
const c = canon(child);
|
|
592
|
+
return parentRoots.some((r) => {
|
|
593
|
+
const pr = canon(r);
|
|
594
|
+
return isWithin(c, pr) && isWithin(child, r);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function hostCovered(child, parent) {
|
|
599
|
+
if (child === parent) return true;
|
|
600
|
+
if (!parent.startsWith('*.')) return false;
|
|
601
|
+
const base = parent.slice(2);
|
|
602
|
+
const c = child.startsWith('*.') ? child.slice(2) : child;
|
|
603
|
+
return c !== base && c.endsWith(`.${base}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function networkCovered(c, p) {
|
|
607
|
+
if (!hostCovered(c.host, p.host) || c.port !== p.port) return false;
|
|
608
|
+
if (!c.schemes.every((s) => p.schemes.includes(s))) return false;
|
|
609
|
+
if (c.allowPrivateResolution && !p.allowPrivateResolution) return false;
|
|
610
|
+
if (p.addresses) { if (!c.addresses || !c.addresses.every((a) => p.addresses.includes(a))) return false; }
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function commandCovered(c, p) {
|
|
615
|
+
if (canon(c.executable) !== canon(p.executable)) return false;
|
|
616
|
+
if (c.interpreter && !p.interpreter) return false;
|
|
617
|
+
const pv = p.args.values; const cv = c.args.values;
|
|
618
|
+
if (p.args.mode === 'any') return true;
|
|
619
|
+
if (p.args.mode === 'exact') return c.args.mode === 'exact' && cv.length === pv.length && cv.every((v, i) => v === pv[i]);
|
|
620
|
+
// parent prefix
|
|
621
|
+
return c.args.mode !== 'any' && cv.length >= pv.length && pv.every((v, i) => v === cv[i]);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Build a child task's manifest from a request, only if it is a subset of the
|
|
626
|
+
* parent's. Omitted repository, policy version and resource limits are
|
|
627
|
+
* inherited; anything else that is omitted is simply not granted. Returns
|
|
628
|
+
* `{ ok, errors, manifest }`; on any widening `ok` is false with
|
|
629
|
+
* `SCOPE_EXPANSION` errors and NO manifest, so there is no partial grant.
|
|
630
|
+
*
|
|
631
|
+
* @param {object} parent a normalized manifest (from `validateManifest`/`bindManifest`)
|
|
632
|
+
* @param {object} request manifest-shaped; `taskId` required
|
|
633
|
+
*/
|
|
634
|
+
function deriveChild(parent, request) {
|
|
635
|
+
const errors = [];
|
|
636
|
+
const pv = manifest_validateManifest(parent);
|
|
637
|
+
if (!pv.ok) return { ok: false, errors: [{ code: 'RULE_VIOLATION', path: 'parent', message: 'the parent manifest is invalid' }], manifest: null };
|
|
638
|
+
const p = pv.manifest;
|
|
639
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(request)) return { ok: false, errors: [{ code: 'NOT_AN_OBJECT', path: '', message: 'request must be an object' }], manifest: null };
|
|
640
|
+
|
|
641
|
+
const merged = { ...request, schema: MANIFEST_SCHEMA, schemaVersion: request.schemaVersion ?? p.schemaVersion };
|
|
642
|
+
merged.parentTaskId = p.taskId;
|
|
643
|
+
if (request.parentTaskId !== undefined && request.parentTaskId !== null && request.parentTaskId !== p.taskId) {
|
|
644
|
+
errors.push({ code: 'SCOPE_EXPANSION', path: 'parentTaskId', message: 'a child names its actual parent' });
|
|
645
|
+
}
|
|
646
|
+
merged.repository = request.repository ?? { revision: p.repository.revision };
|
|
647
|
+
merged.policyVersion = request.policyVersion ?? p.policyVersion;
|
|
648
|
+
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' });
|
|
649
|
+
if (merged.policyVersion !== p.policyVersion) errors.push({ code: 'SCOPE_EXPANSION', path: 'policyVersion', message: 'a child is bound to its parent\'s policy version' });
|
|
650
|
+
if (request.taskId === p.taskId) errors.push({ code: 'SCOPE_EXPANSION', path: 'taskId', message: 'a child has its own task id' });
|
|
651
|
+
// Inherit limits the parent set; the child may only lower them.
|
|
652
|
+
merged.resources = { ...(p.resources), ...((0,schema_kit/* isPlainObject */.Qd)(request.resources) ? request.resources : {}) };
|
|
653
|
+
|
|
654
|
+
const cv = manifest_validateManifest(merged);
|
|
655
|
+
if (!cv.ok) return { ok: false, errors: [...errors, ...cv.errors], manifest: null };
|
|
656
|
+
const c = cv.manifest;
|
|
657
|
+
|
|
658
|
+
const parentReadable = [...p.filesystem.read, ...p.filesystem.write];
|
|
659
|
+
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' }); });
|
|
660
|
+
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' }); });
|
|
661
|
+
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' }); });
|
|
662
|
+
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' }); });
|
|
663
|
+
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' }); });
|
|
664
|
+
for (const [k, v] of Object.entries(c.resources)) {
|
|
665
|
+
if (p.resources[k] !== undefined && v > p.resources[k]) errors.push({ code: 'SCOPE_EXPANSION', path: `resources.${k}`, message: 'higher than the parent limit' });
|
|
666
|
+
}
|
|
667
|
+
if (c.delegation.allow) {
|
|
668
|
+
if (!p.delegation.allow) errors.push({ code: 'SCOPE_EXPANSION', path: 'delegation.allow', message: 'the parent may not delegate' });
|
|
669
|
+
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' });
|
|
670
|
+
}
|
|
671
|
+
if (errors.length) return { ok: false, errors, manifest: null };
|
|
672
|
+
return { ok: true, errors: [], manifest: c };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// EXTERNAL MODULE: ./src/posture/assurance/contracts.js
|
|
676
|
+
var contracts = __webpack_require__(49928);
|
|
677
|
+
;// CONCATENATED MODULE: ./src/capabilities/decide.js
|
|
678
|
+
// Deterministic capability decisions (X-501.AC02, X-501.AC03).
|
|
679
|
+
//
|
|
680
|
+
// `decide(bound, action, ctx)` answers one question: does this task's bound
|
|
681
|
+
// manifest permit this one action? The answer is a frozen
|
|
682
|
+
// `{ decision, code, reason, kind, subject, taskId, manifestDigest }`:
|
|
683
|
+
//
|
|
684
|
+
// - deny-by-default: the answer is `allow` only when a specific declared grant
|
|
685
|
+
// covers the action; every other path, including every error, is `deny`
|
|
686
|
+
// - deterministic: no clock, no randomness, no environment; the same bound
|
|
687
|
+
// manifest, action and context give the same answer (the filesystem is read
|
|
688
|
+
// only to resolve symbolic links and to look at an executable)
|
|
689
|
+
// - sanitized: `code` comes from the closed table in reasons.js and `reason` is
|
|
690
|
+
// the fixed sentence beside it; the one request-derived field, `subject`, is
|
|
691
|
+
// redacted and length-capped
|
|
692
|
+
// - fail-closed: an unknown action kind, a binding that does not match, an
|
|
693
|
+
// argument list that is not plain strings, a path that cannot be resolved,
|
|
694
|
+
// an interpreter that is not declared as one: all `deny`
|
|
695
|
+
//
|
|
696
|
+
// This is a POLICY decision. Whether the operating system then actually stops the
|
|
697
|
+
// task from doing what the decision denied is the runner's job (runner.js), and
|
|
698
|
+
// only the runner may claim enforcement. A hook or an in-process check that calls
|
|
699
|
+
// this function explains; it does not enforce.
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
const decide_MAX_ARGS = 256;
|
|
711
|
+
const decide_MAX_ARG_LEN = 8192;
|
|
712
|
+
|
|
713
|
+
// Executables that run code supplied elsewhere (their arguments, a script file,
|
|
714
|
+
// standard input): shells, language runtimes, launchers and build drivers. A
|
|
715
|
+
// shell allowlist cannot see through any of them, so each is refused unless the
|
|
716
|
+
// manifest declares it as a scoped interpreter with its exact argument list.
|
|
717
|
+
const INTERPRETER_NAMES = new Set([
|
|
718
|
+
'sh', 'bash', 'zsh', 'dash', 'ksh', 'csh', 'tcsh', 'fish', 'ash', 'busybox', 'env', 'xargs', 'nice', 'nohup',
|
|
719
|
+
'time', 'timeout', 'sudo', 'doas', 'su', 'ssh', 'osascript', 'launchctl', 'script', 'expect', 'tclsh', 'wish',
|
|
720
|
+
'awk', 'gawk', 'mawk', 'nawk', 'java', 'jshell', 'deno', 'bun', 'nodejs', 'swift', 'make', 'gmake',
|
|
721
|
+
'npm', 'npx', 'yarn', 'pnpm', 'pip', 'pip3', 'pipx', 'gem', 'bundle', 'cargo', 'go', 'gradle', 'mvn', 'watch',
|
|
722
|
+
]);
|
|
723
|
+
const INTERPRETER_FAMILY = /^(?:python|pypy|ruby|perl|php|lua|luajit|node|tclsh)[0-9.]*$/;
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* What kind of executable this is. `interpreter` is true for a known shell or
|
|
727
|
+
* runtime by name, and for any file that starts with a `#!` line (a script,
|
|
728
|
+
* whatever it is called). A name is a hint, the shebang is the file's own word.
|
|
729
|
+
*/
|
|
730
|
+
function classifyExecutable(realPath) {
|
|
731
|
+
const base = external_node_path_.basename(realPath).toLowerCase();
|
|
732
|
+
if (INTERPRETER_NAMES.has(base) || INTERPRETER_FAMILY.test(base)) return { interpreter: true, via: 'name' };
|
|
733
|
+
let fd;
|
|
734
|
+
try {
|
|
735
|
+
fd = external_node_fs_.openSync(realPath, 'r');
|
|
736
|
+
const buf = Buffer.alloc(2);
|
|
737
|
+
const n = external_node_fs_.readSync(fd, buf, 0, 2, 0);
|
|
738
|
+
if (n === 2 && buf[0] === 0x23 && buf[1] === 0x21) return { interpreter: true, via: 'shebang' };
|
|
739
|
+
} catch { /* unreadable: handled by the caller's stat check */ } finally {
|
|
740
|
+
if (fd !== undefined) { try { external_node_fs_.closeSync(fd); } catch { /* ignore */ } }
|
|
741
|
+
}
|
|
742
|
+
return { interpreter: false, via: null };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function result(bound, action, decision, code, subject) {
|
|
746
|
+
return Object.freeze({
|
|
747
|
+
decision, code, reason: reasonText(code), kind: typeof action?.kind === 'string' ? reasons_sanitizeSubject(action.kind, 40) : 'unknown',
|
|
748
|
+
subject: reasons_sanitizeSubject(subject), taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function argsDigest(args) {
|
|
753
|
+
return external_node_crypto_.createHash('sha256').update(JSON.stringify(args)).digest('hex').slice(0, 12);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ---------------------------------------------------------------- filesystem
|
|
757
|
+
|
|
758
|
+
function decideFilesystem(bound, action, ctx) {
|
|
759
|
+
const write = action.kind === 'filesystem-write';
|
|
760
|
+
const subject = typeof action.path === 'string' ? action.path : '(not a string)';
|
|
761
|
+
const lex = lexicalPath(action.path);
|
|
762
|
+
if (!lex.ok) return result(bound, action, 'deny', 'path-invalid', subject);
|
|
763
|
+
// Any parent-directory segment is refused outright. Lexical normalization of
|
|
764
|
+
// `link/..` disagrees with what the kernel does when `link` is a symlink, so
|
|
765
|
+
// a path containing one cannot be judged by its normalized form.
|
|
766
|
+
if (lex.dotdot) return result(bound, action, 'deny', 'path-traversal', subject);
|
|
767
|
+
const real = canonicalPath(lex.path);
|
|
768
|
+
if (!real) return result(bound, action, 'deny', 'path-invalid', subject);
|
|
769
|
+
|
|
770
|
+
for (const p of ctx.protectedPaths || []) {
|
|
771
|
+
const pl = lexicalPath(p);
|
|
772
|
+
if (!pl.ok) continue;
|
|
773
|
+
const pr = canonicalPath(pl.path) ?? pl.path;
|
|
774
|
+
if (isWithin(real, pr) || isWithin(lex.path, pl.path)) return result(bound, action, 'deny', 'protected-path', subject);
|
|
775
|
+
}
|
|
776
|
+
const fsGrants = bound.manifest.filesystem;
|
|
777
|
+
const roots = write ? fsGrants.write : [...fsGrants.read, ...fsGrants.write];
|
|
778
|
+
if (!roots.length) return result(bound, action, 'deny', 'no-grant', subject);
|
|
779
|
+
const lexicallyIn = roots.some((r) => isWithin(lex.path, r));
|
|
780
|
+
if (!lexicallyIn) return result(bound, action, 'deny', 'outside-roots', subject);
|
|
781
|
+
const really = roots.some((r) => isWithin(real, canonicalPath(r) ?? r));
|
|
782
|
+
if (!really) return result(bound, action, 'deny', 'symlink-escape', subject);
|
|
783
|
+
return result(bound, action, 'allow', 'allowed', subject);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ---------------------------------------------------------------- commands
|
|
787
|
+
|
|
788
|
+
function decideCommand(bound, action, ctx) {
|
|
789
|
+
const exe = action.executable;
|
|
790
|
+
const args = action.args === undefined ? [] : action.args;
|
|
791
|
+
const subjectBase = typeof exe === 'string' ? exe : '(not a string)';
|
|
792
|
+
const lexExe = lexicalPath(exe);
|
|
793
|
+
if (!lexExe.ok || lexExe.dotdot) return result(bound, action, 'deny', 'executable-not-absolute', subjectBase);
|
|
794
|
+
if (!Array.isArray(args) || args.length > decide_MAX_ARGS || !args.every((a) => typeof a === 'string' && a.length <= decide_MAX_ARG_LEN && !a.includes('\0'))) {
|
|
795
|
+
return result(bound, action, 'deny', 'args-invalid', `${subjectBase} (malformed arguments)`);
|
|
796
|
+
}
|
|
797
|
+
const subject = `${subjectBase} (${args.length} args, args sha256:${argsDigest(args)})`;
|
|
798
|
+
|
|
799
|
+
// Arguments are visible to every process on the host and to every descendant.
|
|
800
|
+
// A secret does not belong in one, whatever command it is handed to.
|
|
801
|
+
const canaries = (ctx.canaries || []).filter((c) => typeof c === 'string' && c.length >= 6);
|
|
802
|
+
for (const a of args) {
|
|
803
|
+
let secret = false;
|
|
804
|
+
try { secret = detectSecretShapes(a); } catch { secret = true; }
|
|
805
|
+
if (secret || canaries.some((c) => a.includes(c))) return result(bound, action, 'deny', 'secret-in-argument', subject);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let real; let st;
|
|
809
|
+
try { real = external_node_fs_.realpathSync(lexExe.path); st = external_node_fs_.statSync(real); } catch { return result(bound, action, 'deny', 'executable-unresolvable', subject); }
|
|
810
|
+
if (!st.isFile()) return result(bound, action, 'deny', 'executable-unresolvable', subject);
|
|
811
|
+
try { external_node_fs_.accessSync(real, external_node_fs_.constants.X_OK); } catch { return result(bound, action, 'deny', 'executable-unresolvable', subject); }
|
|
812
|
+
|
|
813
|
+
for (const w of bound.manifest.filesystem.write) {
|
|
814
|
+
if (isWithin(real, canonicalPath(w) ?? w)) return result(bound, action, 'deny', 'executable-in-writable-root', subject);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const entries = bound.manifest.commands.filter((c) => (canonicalPath(c.executable) ?? c.executable) === real);
|
|
818
|
+
if (!entries.length) return result(bound, action, 'deny', 'command-not-listed', subject);
|
|
819
|
+
|
|
820
|
+
const cls = classifyExecutable(real);
|
|
821
|
+
const matches = (e) => {
|
|
822
|
+
const v = e.args.values;
|
|
823
|
+
if (e.args.mode === 'any') return true;
|
|
824
|
+
if (e.args.mode === 'exact') return v.length === args.length && v.every((x, i) => x === args[i]);
|
|
825
|
+
return args.length >= v.length && v.every((x, i) => x === args[i]);
|
|
826
|
+
};
|
|
827
|
+
const argOk = entries.filter(matches);
|
|
828
|
+
if (!argOk.length) {
|
|
829
|
+
// An interpreter whose entries pin different arguments is the unpinned case.
|
|
830
|
+
if (cls.interpreter && !entries.some((e) => e.interpreter)) return result(bound, action, 'deny', 'interpreter-blocked', subject);
|
|
831
|
+
return result(bound, action, 'deny', 'args-not-permitted', subject);
|
|
832
|
+
}
|
|
833
|
+
if (cls.interpreter) {
|
|
834
|
+
const scoped = argOk.find((e) => e.interpreter === 'scoped');
|
|
835
|
+
if (!scoped) return result(bound, action, 'deny', 'interpreter-blocked', subject);
|
|
836
|
+
if (scoped.args.mode !== 'exact') return result(bound, action, 'deny', 'interpreter-args-unpinned', subject);
|
|
837
|
+
}
|
|
838
|
+
return result(bound, action, 'allow', 'allowed', subject);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------- network
|
|
842
|
+
|
|
843
|
+
function hostMatches(entryHost, host) {
|
|
844
|
+
if (entryHost === host) return true;
|
|
845
|
+
if (!entryHost.startsWith('*.')) return false;
|
|
846
|
+
const base = entryHost.slice(2);
|
|
847
|
+
return host !== base && host.endsWith(`.${base}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function decideNetwork(bound, action) {
|
|
851
|
+
const h = address_normalizeHost(action.host);
|
|
852
|
+
const subject = typeof action.host === 'string' ? `${action.scheme ?? '?'}://${action.host}:${action.port ?? '?'}` : '(not a string)';
|
|
853
|
+
if (!h.ok) return result(bound, action, 'deny', 'host-invalid', subject);
|
|
854
|
+
if (!Number.isInteger(action.port) || action.port < 1 || action.port > 65535) return result(bound, action, 'deny', 'port-invalid', subject);
|
|
855
|
+
const scheme = action.scheme === undefined ? 'https' : action.scheme;
|
|
856
|
+
if (scheme !== 'http' && scheme !== 'https') return result(bound, action, 'deny', 'scheme-not-declared', subject);
|
|
857
|
+
const onHost = bound.manifest.network.filter((d) => hostMatches(d.host, h.host));
|
|
858
|
+
if (!onHost.length) return result(bound, action, 'deny', 'destination-not-declared', subject);
|
|
859
|
+
const onPort = onHost.filter((d) => d.port === action.port);
|
|
860
|
+
if (!onPort.length) return result(bound, action, 'deny', 'port-not-declared', subject);
|
|
861
|
+
const onScheme = onPort.filter((d) => d.schemes.includes(scheme));
|
|
862
|
+
if (!onScheme.length) return result(bound, action, 'deny', 'scheme-not-declared', subject);
|
|
863
|
+
|
|
864
|
+
// Address checks apply to a host NAME once it has been resolved. The caller
|
|
865
|
+
// (the proxy) resolves once, passes the addresses here and connects to those
|
|
866
|
+
// same addresses, so what is checked is what is connected to.
|
|
867
|
+
const resolved = Array.isArray(action.resolvedAddresses) ? action.resolvedAddresses : null;
|
|
868
|
+
if (h.kind === 'name' && resolved) {
|
|
869
|
+
if (!resolved.length) return result(bound, action, 'deny', 'dns-private-address', subject);
|
|
870
|
+
const verdicts = onScheme.map((d) => {
|
|
871
|
+
if (d.addresses && !resolved.every((a) => d.addresses.includes(a))) return 'dns-changed';
|
|
872
|
+
if (!d.allowPrivateResolution && resolved.some((a) => address_classifyAddress(a) !== 'public')) return 'dns-private-address';
|
|
873
|
+
return 'ok';
|
|
874
|
+
});
|
|
875
|
+
if (!verdicts.includes('ok')) return result(bound, action, 'deny', verdicts.includes('dns-changed') ? 'dns-changed' : 'dns-private-address', subject);
|
|
876
|
+
}
|
|
877
|
+
return result(bound, action, 'allow', 'allowed', subject);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---------------------------------------------------------------- tools, delegation
|
|
881
|
+
|
|
882
|
+
function decideTool(bound, action) {
|
|
883
|
+
const subject = typeof action.tool === 'string' ? action.tool : '(not a string)';
|
|
884
|
+
if (typeof action.tool !== 'string' || !bound.manifest.tools.includes(action.tool)) return result(bound, action, 'deny', 'tool-not-declared', subject);
|
|
885
|
+
return result(bound, action, 'allow', 'allowed', subject);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function decideDelegation(bound, action) {
|
|
889
|
+
const d = bound.manifest.delegation;
|
|
890
|
+
const depth = Number.isInteger(action.depth) && action.depth >= 0 ? action.depth : 0;
|
|
891
|
+
const subject = `delegation at depth ${depth}`;
|
|
892
|
+
if (!d.allow) return result(bound, action, 'deny', 'delegation-not-allowed', subject);
|
|
893
|
+
if (depth >= d.maxDepth) return result(bound, action, 'deny', 'delegation-depth', subject);
|
|
894
|
+
if (action.request !== undefined) {
|
|
895
|
+
const child = deriveChild(bound.manifest, action.request);
|
|
896
|
+
if (!child.ok) return result(bound, action, 'deny', 'scope-expansion', subject);
|
|
897
|
+
}
|
|
898
|
+
return result(bound, action, 'allow', 'allowed', subject);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* @param {{manifest: object, binding: object}} bound from `bindManifest`
|
|
903
|
+
* @param {object} action `{ kind, ... }`; kind is one of CAPABILITY_KINDS
|
|
904
|
+
* @param {object} ctx
|
|
905
|
+
* @param {{taskId:string, revision:string, policyVersion:number}} ctx.binding what the caller believes it is acting for
|
|
906
|
+
* @param {string[]} [ctx.protectedPaths] paths no task may touch, whatever the manifest says
|
|
907
|
+
* @param {string[]} [ctx.canaries] values that must never appear in an argument
|
|
908
|
+
*/
|
|
909
|
+
function decide_decide(bound, action, ctx = {}) {
|
|
910
|
+
if (!bound || !bound.manifest || !bound.binding) return result(bound, action, 'deny', 'invalid-manifest', '(no manifest)');
|
|
911
|
+
const b = ctx.binding;
|
|
912
|
+
const bb = bound.binding;
|
|
913
|
+
if (!b || b.taskId !== bb.taskId || b.revision !== bb.revision || b.policyVersion !== bb.policyVersion) {
|
|
914
|
+
return result(bound, action, 'deny', 'binding-mismatch', '(binding)');
|
|
915
|
+
}
|
|
916
|
+
if (!action || typeof action !== 'object' || Array.isArray(action) || !contracts/* CAPABILITY_KINDS */.bq.includes(action.kind)) {
|
|
917
|
+
return result(bound, action, 'deny', 'unknown-action', typeof action?.kind === 'string' ? action.kind : '(no kind)');
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
switch (action.kind) {
|
|
921
|
+
case 'filesystem-read':
|
|
922
|
+
case 'filesystem-write': return decideFilesystem(bound, action, ctx);
|
|
923
|
+
case 'command': return decideCommand(bound, action, ctx);
|
|
924
|
+
case 'network': return decideNetwork(bound, action);
|
|
925
|
+
case 'tool': return decideTool(bound, action);
|
|
926
|
+
case 'delegation': return decideDelegation(bound, action);
|
|
927
|
+
default: return result(bound, action, 'deny', 'unknown-action', action.kind);
|
|
928
|
+
}
|
|
929
|
+
} catch {
|
|
930
|
+
// A decision never throws. An unexpected failure is a denial.
|
|
931
|
+
return result(bound, action, 'deny', 'invalid-manifest', '(decision failed)');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// EXTERNAL MODULE: external "node:http"
|
|
936
|
+
var external_node_http_ = __webpack_require__(37067);
|
|
937
|
+
// EXTERNAL MODULE: external "node:os"
|
|
938
|
+
var external_node_os_ = __webpack_require__(48161);
|
|
939
|
+
// EXTERNAL MODULE: ./src/sandbox/capabilities.js
|
|
940
|
+
var capabilities = __webpack_require__(60450);
|
|
941
|
+
// EXTERNAL MODULE: ./src/sandbox/supervise.js
|
|
942
|
+
var supervise = __webpack_require__(74676);
|
|
943
|
+
// EXTERNAL MODULE: ./src/sandbox/control-probes.js
|
|
944
|
+
var control_probes = __webpack_require__(39899);
|
|
945
|
+
// EXTERNAL MODULE: external "node:dns"
|
|
946
|
+
var external_node_dns_ = __webpack_require__(40610);
|
|
947
|
+
// EXTERNAL MODULE: ./src/egress/audit.js
|
|
948
|
+
var audit = __webpack_require__(37355);
|
|
949
|
+
;// CONCATENATED MODULE: ./src/capabilities/outbound.js
|
|
950
|
+
// Outbound payload filtering and denial records (X-504.AC02, X-504.AC03).
|
|
951
|
+
//
|
|
952
|
+
// `redactOutbound` is the structured counterpart of egress/redact.js: that module
|
|
953
|
+
// redacts prompt TEXT bound for a model endpoint, this one redacts a whole
|
|
954
|
+
// request (URL, headers, body) bound for a declared network destination. It
|
|
955
|
+
// reuses the same secret detector (`redactSecrets`), then adds what a request has
|
|
956
|
+
// that prose does not: credentials in URL userinfo, secret-named query
|
|
957
|
+
// parameters and header names, and secret-named keys in JSON and form bodies.
|
|
958
|
+
// Exact canary values (and their URL-encoded and base64 forms) are removed
|
|
959
|
+
// wherever they appear, which is what the canary fixtures assert.
|
|
960
|
+
//
|
|
961
|
+
// What it cannot do is look inside an encrypted tunnel. A CONNECT tunnel to a
|
|
962
|
+
// declared https destination is opaque to the proxy; the control for that path is
|
|
963
|
+
// that the task was never handed a secret to put in it (secret-free environment,
|
|
964
|
+
// protected paths, no secrets in arguments), and the capability report says
|
|
965
|
+
// `payloadFiltering: plaintext-http-only`.
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
const PLACEHOLDER = '[REDACTED-SECRET]';
|
|
973
|
+
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;
|
|
974
|
+
const MAX_DEPTH = 32;
|
|
975
|
+
|
|
976
|
+
function isSecretName(name) {
|
|
977
|
+
return typeof name === 'string' && SECRET_NAME.test(name);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The forms in which a canary value could travel: raw, URL-encoded, base64 and its URL-safe variant. */
|
|
981
|
+
function canaryForms(canaries) {
|
|
982
|
+
const out = new Set();
|
|
983
|
+
for (const c of canaries || []) {
|
|
984
|
+
if (typeof c !== 'string' || c.length < 6) continue;
|
|
985
|
+
out.add(c);
|
|
986
|
+
out.add(encodeURIComponent(c));
|
|
987
|
+
const b64 = Buffer.from(c, 'utf8').toString('base64');
|
|
988
|
+
out.add(b64);
|
|
989
|
+
out.add(b64.replace(/=+$/, ''));
|
|
990
|
+
out.add(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''));
|
|
991
|
+
}
|
|
992
|
+
return [...out].filter((x) => x.length >= 6);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function scrubText(text, forms, stats) {
|
|
996
|
+
let out = String(text);
|
|
997
|
+
for (const f of forms) {
|
|
998
|
+
if (out.includes(f)) { out = out.split(f).join(PLACEHOLDER); stats.canary += 1; }
|
|
999
|
+
}
|
|
1000
|
+
const r = scrubSecretText(out);
|
|
1001
|
+
if (r.redactions > 0) { stats.secrets += r.redactions; out = r.text; }
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function walk(value, forms, stats, depth = 0) {
|
|
1006
|
+
if (depth > MAX_DEPTH) { stats.truncated = true; return PLACEHOLDER; }
|
|
1007
|
+
if (typeof value === 'string') return scrubText(value, forms, stats);
|
|
1008
|
+
if (Array.isArray(value)) return value.map((v) => walk(v, forms, stats, depth + 1));
|
|
1009
|
+
if (value && typeof value === 'object') {
|
|
1010
|
+
const out = {};
|
|
1011
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1012
|
+
const key = scrubText(k, forms, stats);
|
|
1013
|
+
if (isSecretName(k) && (typeof v === 'string' || typeof v === 'number')) { out[key] = PLACEHOLDER; stats.keys += 1; }
|
|
1014
|
+
else out[key] = walk(v, forms, stats, depth + 1);
|
|
1015
|
+
}
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
return value;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function scrubParams(params, forms, stats) {
|
|
1022
|
+
const out = new URLSearchParams();
|
|
1023
|
+
for (const [k, v] of params) {
|
|
1024
|
+
if (isSecretName(k) && v !== '') { out.append(scrubText(k, forms, stats), PLACEHOLDER); stats.keys += 1; }
|
|
1025
|
+
else out.append(scrubText(k, forms, stats), scrubText(v, forms, stats));
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Filter a request before it leaves.
|
|
1032
|
+
* @param {{url?: string, headers?: object, body?: string|Buffer|object, contentType?: string}} req
|
|
1033
|
+
* @param {{canaries?: string[]}} [opts]
|
|
1034
|
+
* @returns {{url: string|null, headers: object, body: string|null, redactions: number,
|
|
1035
|
+
* categories: {userinfo:number, secrets:number, keys:number, canary:number}, uninspectable: boolean}}
|
|
1036
|
+
* `uninspectable` is true for a binary body, which is returned untouched and
|
|
1037
|
+
* must not be sent by a caller that requires filtering.
|
|
1038
|
+
*/
|
|
1039
|
+
function outbound_redactOutbound(req = {}, opts = {}) {
|
|
1040
|
+
const forms = canaryForms(opts.canaries);
|
|
1041
|
+
const stats = { secrets: 0, keys: 0, canary: 0, userinfo: 0, truncated: false };
|
|
1042
|
+
let url = null;
|
|
1043
|
+
if (typeof req.url === 'string') {
|
|
1044
|
+
try {
|
|
1045
|
+
const u = new URL(req.url);
|
|
1046
|
+
if (u.username || u.password) { stats.userinfo += 1; u.username = ''; u.password = ''; }
|
|
1047
|
+
u.search = scrubParams(u.searchParams, forms, stats).toString();
|
|
1048
|
+
u.pathname = u.pathname.split('/').map((seg) => scrubText(decodeURIComponent(seg), forms, stats)).map(encodeURIComponent).join('/');
|
|
1049
|
+
u.hash = '';
|
|
1050
|
+
url = u.toString();
|
|
1051
|
+
} catch {
|
|
1052
|
+
url = scrubText(req.url.replace(/\/\/[^/@\s]*@/, '//'), forms, stats);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const headers = {};
|
|
1056
|
+
for (const [name, raw] of Object.entries(req.headers || {})) {
|
|
1057
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
1058
|
+
const cleaned = values.map((v) => {
|
|
1059
|
+
if (isSecretName(name)) { stats.keys += 1; return PLACEHOLDER; }
|
|
1060
|
+
return scrubText(String(v), forms, stats);
|
|
1061
|
+
});
|
|
1062
|
+
headers[name.toLowerCase()] = Array.isArray(raw) ? cleaned : cleaned[0];
|
|
1063
|
+
}
|
|
1064
|
+
let body = null;
|
|
1065
|
+
let uninspectable = false;
|
|
1066
|
+
if (req.body !== undefined && req.body !== null) {
|
|
1067
|
+
const ct = String(req.contentType || headers['content-type'] || '').toLowerCase();
|
|
1068
|
+
if (Buffer.isBuffer(req.body) && req.body.includes(0)) {
|
|
1069
|
+
uninspectable = true; body = req.body;
|
|
1070
|
+
} else if (typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
|
|
1071
|
+
body = JSON.stringify(walk(req.body, forms, stats));
|
|
1072
|
+
} else {
|
|
1073
|
+
const text = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body);
|
|
1074
|
+
if (ct.includes('json')) {
|
|
1075
|
+
try { body = JSON.stringify(walk(JSON.parse(text), forms, stats)); } catch { body = scrubText(text, forms, stats); }
|
|
1076
|
+
} else if (ct.includes('x-www-form-urlencoded')) {
|
|
1077
|
+
body = scrubParams(new URLSearchParams(text), forms, stats).toString();
|
|
1078
|
+
} else body = scrubText(text, forms, stats);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const categories = { userinfo: stats.userinfo, secrets: stats.secrets, keys: stats.keys, canary: stats.canary };
|
|
1082
|
+
return {
|
|
1083
|
+
url, headers, body, categories, uninspectable,
|
|
1084
|
+
redactions: stats.userinfo + stats.secrets + stats.keys + stats.canary,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Log-safe text: canary values and secret shapes removed. Never used to build a decision reason. */
|
|
1089
|
+
function sanitizeLogText(text, canaries = []) {
|
|
1090
|
+
let out = typeof text === 'string' ? text : JSON.stringify(text) ?? '';
|
|
1091
|
+
for (const f of canaryForms(canaries)) out = out.split(f).join(PLACEHOLDER);
|
|
1092
|
+
// Provider-shaped credentials only. The high-entropy-literal heuristic is for
|
|
1093
|
+
// payloads, where over-redaction is acceptable; here it would rewrite ordinary
|
|
1094
|
+
// quoted paths in a task's output.
|
|
1095
|
+
return redactSecretShapes(out).text;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ---------------------------------------------------------------- denial records
|
|
1099
|
+
|
|
1100
|
+
/** Class of a destination for the audit trail: an address class, or `hostname`. */
|
|
1101
|
+
function destinationClass(host) {
|
|
1102
|
+
const h = normalizeHost(host);
|
|
1103
|
+
if (!h.ok) return 'invalid';
|
|
1104
|
+
if (h.kind === 'name') return h.host === 'localhost' || h.host.endsWith('.localhost') ? 'loopback' : 'hostname';
|
|
1105
|
+
return classifyAddress(h.host);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* What a denied egress leaves behind: the destination CLASS, a short digest of
|
|
1110
|
+
* the host (a host name can itself carry data, so it is never stored), the port,
|
|
1111
|
+
* the scheme and the policy code. No path, query, header or body, ever.
|
|
1112
|
+
*/
|
|
1113
|
+
function outbound_denialRecord({ taskId, host, port, scheme, code }) {
|
|
1114
|
+
const h = normalizeHost(host);
|
|
1115
|
+
const digest = crypto.createHash('sha256').update(h.ok ? h.host : String(host)).digest('hex').slice(0, 12);
|
|
1116
|
+
return Object.freeze({
|
|
1117
|
+
taskId: taskId ?? null, outcome: 'deny', code, destinationClass: destinationClass(host),
|
|
1118
|
+
hostDigest: digest, port: Number.isInteger(port) ? port : null, scheme: scheme === 'http' || scheme === 'https' ? scheme : null,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Append a denial to the existing tamper-evident egress audit chain
|
|
1124
|
+
* (egress/audit.js). The chain records provider, purpose, outcome and reason, so
|
|
1125
|
+
* the destination class is carried as the provider and the policy code as the
|
|
1126
|
+
* reason; nothing else is passed in.
|
|
1127
|
+
*/
|
|
1128
|
+
function outbound_recordNetworkDenial(scanRoot, record) {
|
|
1129
|
+
if (!scanRoot || !record) return;
|
|
1130
|
+
recordEgressCall({
|
|
1131
|
+
scanRoot,
|
|
1132
|
+
decision: {
|
|
1133
|
+
allowed: false, decision: 'deny', reason: record.code, provider: record.destinationClass,
|
|
1134
|
+
policySource: 'capability-manifest', purpose: 'capability-network',
|
|
1135
|
+
},
|
|
1136
|
+
ctx: {},
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
;// CONCATENATED MODULE: ./src/capabilities/proxy.js
|
|
1141
|
+
// The mediated network boundary (X-504.AC01, X-504.AC03).
|
|
1142
|
+
//
|
|
1143
|
+
// The runner starts one of these per task that declares network destinations and
|
|
1144
|
+
// opens exactly its loopback port in the sandbox profile. Everything else is
|
|
1145
|
+
// closed by the operating system: DNS, every other loopback port, every remote
|
|
1146
|
+
// address, every unix socket. A child, a descendant or an interpreter that
|
|
1147
|
+
// ignores the proxy settings and opens a socket itself is refused by the kernel,
|
|
1148
|
+
// not by this file; this file decides what the ONE open door lets through.
|
|
1149
|
+
//
|
|
1150
|
+
// Per request:
|
|
1151
|
+
// 1. the destination is checked against the bound manifest (`decide`, kind
|
|
1152
|
+
// 'network') before any name is resolved
|
|
1153
|
+
// 2. the name is resolved ONCE, in this process (the sandbox cannot resolve),
|
|
1154
|
+
// the resolved addresses are checked (a declared name that resolves to a
|
|
1155
|
+
// loopback, private, link-local or metadata address is refused unless the
|
|
1156
|
+
// entry says so; a pinned address set must match), and the connection goes to
|
|
1157
|
+
// the address that was checked, so a name that changes between check and
|
|
1158
|
+
// connect cannot redirect the connection
|
|
1159
|
+
// 3. plaintext HTTP is buffered (bounded), filtered with `redactOutbound`, and
|
|
1160
|
+
// forwarded; a binary body that cannot be inspected is refused
|
|
1161
|
+
// 4. HTTPS is an opaque CONNECT tunnel, allowed only to a destination declared
|
|
1162
|
+
// for the https scheme. Its payload is NOT inspected and the report says so.
|
|
1163
|
+
// 5. a redirect is returned to the client unchanged and never followed here, so
|
|
1164
|
+
// the next hop arrives as a new request and meets step 1 again
|
|
1165
|
+
//
|
|
1166
|
+
// A denial records the destination class, a digest of the host, the port and the
|
|
1167
|
+
// policy code. It never records a path, header, body or the host name itself.
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
const HOP_BY_HOP = new Set([
|
|
1176
|
+
'connection', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'keep-alive', 'te', 'trailer',
|
|
1177
|
+
'transfer-encoding', 'upgrade', 'content-length',
|
|
1178
|
+
]);
|
|
1179
|
+
const MAX_RECORDS = 1000;
|
|
1180
|
+
|
|
1181
|
+
async function defaultResolve(host) {
|
|
1182
|
+
const rows = await dns.promises.lookup(host, { all: true });
|
|
1183
|
+
return rows.map((r) => r.address);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* @param {object} o
|
|
1188
|
+
* @param {{manifest: object, binding: object}} o.bound
|
|
1189
|
+
* @param {string[]} [o.canaries]
|
|
1190
|
+
* @param {(host:string)=>Promise<string[]>} [o.resolve] test seam for name resolution
|
|
1191
|
+
* @param {number} [o.maxRequestBytes]
|
|
1192
|
+
* @param {string} [o.scanRoot] when set, denials are appended to the egress audit chain
|
|
1193
|
+
* @param {number} [o.connectTimeoutMs]
|
|
1194
|
+
*/
|
|
1195
|
+
async function proxy_startMediationProxy({
|
|
1196
|
+
bound, canaries = [], resolve = defaultResolve, maxRequestBytes = 1024 * 1024, scanRoot = null, connectTimeoutMs = 8000,
|
|
1197
|
+
}) {
|
|
1198
|
+
const records = [];
|
|
1199
|
+
const sockets = new Set();
|
|
1200
|
+
const stats = { allowed: 0, denied: 0, redactions: 0, bytesForwarded: 0 };
|
|
1201
|
+
const ctx = { binding: bound.binding, canaries };
|
|
1202
|
+
|
|
1203
|
+
const note = (outcome, code, host, port, scheme) => {
|
|
1204
|
+
const rec = denialRecord({ taskId: bound.binding.taskId, host, port, scheme, code });
|
|
1205
|
+
const entry = { ...rec, outcome };
|
|
1206
|
+
if (records.length < MAX_RECORDS) records.push(entry);
|
|
1207
|
+
if (outcome === 'deny') { stats.denied += 1; recordNetworkDenial(scanRoot, rec); } else stats.allowed += 1;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
// Decide on the declared destination, resolve, decide again on the addresses.
|
|
1211
|
+
async function authorize(rawHost, port, scheme) {
|
|
1212
|
+
const first = decide(bound, { kind: 'network', host: rawHost, port, scheme }, ctx);
|
|
1213
|
+
if (first.decision !== 'allow') return { ok: false, code: first.code };
|
|
1214
|
+
const h = normalizeHost(rawHost);
|
|
1215
|
+
let addresses;
|
|
1216
|
+
if (h.kind === 'name') {
|
|
1217
|
+
try { addresses = await resolve(h.host); } catch { addresses = []; }
|
|
1218
|
+
if (!Array.isArray(addresses) || !addresses.every((a) => net.isIP(a))) addresses = [];
|
|
1219
|
+
} else addresses = [h.host];
|
|
1220
|
+
const second = decide(bound, { kind: 'network', host: rawHost, port, scheme, resolvedAddresses: addresses }, ctx);
|
|
1221
|
+
if (second.decision !== 'allow') return { ok: false, code: second.code };
|
|
1222
|
+
return { ok: true, address: addresses[0], host: h.host };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function deny(res, code) {
|
|
1226
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
1227
|
+
const body = JSON.stringify({ blocked: true, code });
|
|
1228
|
+
res.writeHead(403, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), connection: 'close' });
|
|
1229
|
+
res.end(body);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const server = http.createServer((req, res) => {
|
|
1233
|
+
handle(req, res).catch(() => deny(res, 'invalid-manifest'));
|
|
1234
|
+
});
|
|
1235
|
+
server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
|
|
1236
|
+
server.keepAliveTimeout = 1000;
|
|
1237
|
+
|
|
1238
|
+
async function handle(req, res) {
|
|
1239
|
+
let target;
|
|
1240
|
+
try { target = new URL(req.url); } catch { target = null; }
|
|
1241
|
+
if (!target || target.protocol !== 'http:') {
|
|
1242
|
+
// An origin-form request is a client talking to the proxy as if it were a
|
|
1243
|
+
// server; an https:// absolute URI is a client that skipped CONNECT.
|
|
1244
|
+
note('deny', 'destination-not-declared', req.headers.host || '', 0, null);
|
|
1245
|
+
return deny(res, 'destination-not-declared');
|
|
1246
|
+
}
|
|
1247
|
+
const port = target.port ? Number(target.port) : 80;
|
|
1248
|
+
const auth = await authorize(target.hostname, port, 'http');
|
|
1249
|
+
if (!auth.ok) { note('deny', auth.code, target.hostname, port, 'http'); return deny(res, auth.code); }
|
|
1250
|
+
|
|
1251
|
+
const chunks = []; let size = 0; let tooBig = false;
|
|
1252
|
+
await new Promise((done) => {
|
|
1253
|
+
req.on('data', (c) => { size += c.length; if (size > maxRequestBytes) { tooBig = true; req.destroy(); done(); } else chunks.push(c); });
|
|
1254
|
+
req.on('end', done); req.on('error', done); req.on('close', done);
|
|
1255
|
+
});
|
|
1256
|
+
if (tooBig) { note('deny', 'payload-too-large', target.hostname, port, 'http'); return deny(res, 'payload-too-large'); }
|
|
1257
|
+
const body = chunks.length ? Buffer.concat(chunks) : null;
|
|
1258
|
+
const filtered = redactOutbound({ url: req.url, headers: req.headers, body, contentType: req.headers['content-type'] }, { canaries });
|
|
1259
|
+
if (filtered.uninspectable) { note('deny', 'payload-uninspectable', target.hostname, port, 'http'); return deny(res, 'payload-uninspectable'); }
|
|
1260
|
+
stats.redactions += filtered.redactions;
|
|
1261
|
+
|
|
1262
|
+
const out = {};
|
|
1263
|
+
for (const [k, v] of Object.entries(filtered.headers)) if (!HOP_BY_HOP.has(k) && k !== 'host') out[k] = v;
|
|
1264
|
+
out.host = target.port ? `${target.hostname}:${target.port}` : target.hostname;
|
|
1265
|
+
out.connection = 'close';
|
|
1266
|
+
if (filtered.body !== null) out['content-length'] = Buffer.byteLength(filtered.body);
|
|
1267
|
+
const fu = new URL(filtered.url);
|
|
1268
|
+
const upstream = http.request({
|
|
1269
|
+
host: auth.address, port, method: req.method, path: `${fu.pathname}${fu.search}`, headers: out, setHost: false,
|
|
1270
|
+
timeout: connectTimeoutMs, agent: false,
|
|
1271
|
+
});
|
|
1272
|
+
note('allow', 'allowed', target.hostname, port, 'http');
|
|
1273
|
+
upstream.on('timeout', () => upstream.destroy(new Error('timeout')));
|
|
1274
|
+
upstream.on('error', () => deny(res, 'destination-not-declared'));
|
|
1275
|
+
upstream.on('response', (up) => {
|
|
1276
|
+
const headers = {};
|
|
1277
|
+
for (const [k, v] of Object.entries(up.headers)) if (!HOP_BY_HOP.has(k)) headers[k] = v;
|
|
1278
|
+
headers.connection = 'close';
|
|
1279
|
+
res.writeHead(up.statusCode || 502, headers);
|
|
1280
|
+
up.on('data', (c) => { stats.bytesForwarded += c.length; });
|
|
1281
|
+
up.pipe(res);
|
|
1282
|
+
});
|
|
1283
|
+
if (filtered.body !== null) upstream.write(filtered.body);
|
|
1284
|
+
upstream.end();
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
server.on('connect', (req, client, head) => {
|
|
1288
|
+
sockets.add(client); client.on('close', () => sockets.delete(client));
|
|
1289
|
+
client.on('error', () => client.destroy());
|
|
1290
|
+
(async () => {
|
|
1291
|
+
const m = /^(\[[0-9a-fA-F:.]+\]|[^:\s]+):(\d{1,5})$/.exec(req.url || '');
|
|
1292
|
+
if (!m) { note('deny', 'host-invalid', '', 0, 'https'); client.end('HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n'); return; }
|
|
1293
|
+
const port = Number(m[2]);
|
|
1294
|
+
const auth = await authorize(m[1], port, 'https');
|
|
1295
|
+
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; }
|
|
1296
|
+
const upstream = net.connect({ host: auth.address, port, timeout: connectTimeoutMs });
|
|
1297
|
+
sockets.add(upstream); upstream.on('close', () => sockets.delete(upstream));
|
|
1298
|
+
upstream.on('timeout', () => upstream.destroy());
|
|
1299
|
+
upstream.on('error', () => { client.destroy(); });
|
|
1300
|
+
client.on('close', () => upstream.destroy());
|
|
1301
|
+
upstream.on('connect', () => {
|
|
1302
|
+
note('allow', 'allowed', m[1], port, 'https');
|
|
1303
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
1304
|
+
if (head && head.length) upstream.write(head);
|
|
1305
|
+
upstream.pipe(client); client.pipe(upstream);
|
|
1306
|
+
});
|
|
1307
|
+
})().catch(() => client.destroy());
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
|
|
1311
|
+
const port = server.address().port;
|
|
1312
|
+
return {
|
|
1313
|
+
port,
|
|
1314
|
+
url: `http://127.0.0.1:${port}`,
|
|
1315
|
+
records,
|
|
1316
|
+
stats,
|
|
1317
|
+
async close() {
|
|
1318
|
+
for (const s of sockets) { try { s.destroy(); } catch { /* ignore */ } }
|
|
1319
|
+
await new Promise((r) => server.close(() => r()));
|
|
1320
|
+
},
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
;// CONCATENATED MODULE: ./src/capabilities/probes.js
|
|
1325
|
+
// Active probes for the controls capability enforcement depends on (X-502, X-503,
|
|
1326
|
+
// X-504), and the platform status that follows from them.
|
|
1327
|
+
//
|
|
1328
|
+
// The sandbox already probes write confinement, read denial of named host paths,
|
|
1329
|
+
// environment scrubbing, the no-network default, tree termination and the
|
|
1330
|
+
// file-size limit (sandbox/control-probes.js). Capability enforcement needs three
|
|
1331
|
+
// more, each established the same way: an attack through the real backend that
|
|
1332
|
+
// must fail, paired with a positive control that must succeed, so a probe that
|
|
1333
|
+
// cannot succeed cannot pass for a working control.
|
|
1334
|
+
//
|
|
1335
|
+
// fs-read-confinement a canary outside every declared root is unreadable by
|
|
1336
|
+
// the task, by a descendant, through a symbolic link
|
|
1337
|
+
// planted inside a readable root and through `..`; the
|
|
1338
|
+
// same canary IS readable once its directory is declared
|
|
1339
|
+
// fs-multi-root-write several declared write roots are writable, a read-only
|
|
1340
|
+
// root and an undeclared directory are not
|
|
1341
|
+
// network-mediation a declared destination is reachable through the
|
|
1342
|
+
// mediation proxy, an undeclared one is refused by the
|
|
1343
|
+
// proxy, and a direct socket to either is refused by the
|
|
1344
|
+
// operating system
|
|
1345
|
+
//
|
|
1346
|
+
// Nothing here asserts an outcome on a platform it did not run on. On the
|
|
1347
|
+
// namespace backend (Linux) the new controls are `unsupported`, because that
|
|
1348
|
+
// backend does not implement them, and the inherited probes run for real where a
|
|
1349
|
+
// Linux host exists. No state is ever inherited from another platform.
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
const CAPABILITY_CONTROLS = Object.freeze(['fs-read-confinement', 'fs-multi-root-write', 'network-mediation']);
|
|
1364
|
+
|
|
1365
|
+
const NODE = process.execPath;
|
|
1366
|
+
const ZERO_REV = '0'.repeat(40);
|
|
1367
|
+
const proved = (evidence) => ({ state: 'proved', evidence });
|
|
1368
|
+
const notProved = (reason) => ({ state: 'not-proved', reason });
|
|
1369
|
+
const unsupported = (reason) => ({ state: 'unsupported', reason });
|
|
1370
|
+
|
|
1371
|
+
function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
|
|
1372
|
+
function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
1373
|
+
const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1374
|
+
|
|
1375
|
+
async function probeFsRead(backend) {
|
|
1376
|
+
if (backend !== 'userspace') return unsupported(`read confinement to declared roots is not implemented on the ${backend} backend`);
|
|
1377
|
+
const root = mk('agsec-cap-r-'); const ro = mk('agsec-cap-ro-'); const outside = mk('agsec-cap-out-');
|
|
1378
|
+
try {
|
|
1379
|
+
const okTok = token('READABLE'); const secTok = token('SEALED');
|
|
1380
|
+
fs.writeFileSync(path.join(ro, 'r.txt'), okTok);
|
|
1381
|
+
fs.writeFileSync(path.join(outside, 's.txt'), secTok);
|
|
1382
|
+
fs.symlinkSync(path.join(outside, 's.txt'), path.join(ro, 'link'));
|
|
1383
|
+
const run = (script, readRoots) => runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots, timeoutMs: 8000, graceMs: 300 });
|
|
1384
|
+
const pos = await run(`cat '${ro}/r.txt'`, [ro]);
|
|
1385
|
+
if (!pos.stdout.includes(okTok)) return notProved(`positive control failed: a file inside a declared root was not readable (${pos.status})`);
|
|
1386
|
+
const open = await run(`cat '${outside}/s.txt'`, [ro, outside]);
|
|
1387
|
+
if (!open.stdout.includes(secTok)) return notProved('positive control failed: the canary was not readable even when its directory was declared');
|
|
1388
|
+
const attempts = {
|
|
1389
|
+
direct: `cat '${outside}/s.txt'`,
|
|
1390
|
+
'symbolic link': `cat '${ro}/link'`,
|
|
1391
|
+
traversal: `cat '${ro}/../${path.basename(outside)}/s.txt'`,
|
|
1392
|
+
descendant: `sh -c "cat '${outside}/s.txt'"; ( cat '${outside}/s.txt' ) 2>&1`,
|
|
1393
|
+
};
|
|
1394
|
+
for (const [name, script] of Object.entries(attempts)) {
|
|
1395
|
+
const r = await run(`${script}; true`, [ro]);
|
|
1396
|
+
if (r.stdout.includes(secTok)) return notProved(`the canary outside every declared root was readable (${name})`);
|
|
1397
|
+
}
|
|
1398
|
+
return proved('declared roots readable; canary unreadable directly, through a link, through .., and from a descendant; readable once declared');
|
|
1399
|
+
} finally { rm(root); rm(ro); rm(outside); }
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function probeMultiWrite(backend) {
|
|
1403
|
+
if (backend !== 'userspace') return unsupported(`several declared write roots are not implemented on the ${backend} backend`);
|
|
1404
|
+
const root = mk('agsec-cap-w-'); const w1 = mk('agsec-cap-w1-'); const w2 = mk('agsec-cap-w2-');
|
|
1405
|
+
const ro = mk('agsec-cap-wro-'); const outside = mk('agsec-cap-wout-');
|
|
1406
|
+
try {
|
|
1407
|
+
const script = [`echo a > '${w1}/f'`, `echo b > '${w2}/f'`, `echo c > '${ro}/f'`, `echo d > '${outside}/f'`, 'true'].join('; ');
|
|
1408
|
+
await runConfinedSupervised(['/bin/sh', '-c', script], { root, readRoots: [ro], writeRoots: [w1, w2], timeoutMs: 8000, graceMs: 300 });
|
|
1409
|
+
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');
|
|
1410
|
+
if (fs.existsSync(path.join(ro, 'f'))) return notProved('a root declared read-only was writable');
|
|
1411
|
+
if (fs.existsSync(path.join(outside, 'f'))) return notProved('an undeclared directory was writable');
|
|
1412
|
+
return proved('both declared write roots writable; the read-only root and an undeclared directory were not');
|
|
1413
|
+
} finally { rm(root); rm(w1); rm(w2); rm(ro); rm(outside); }
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function listener(label) {
|
|
1417
|
+
const state = { connections: 0 };
|
|
1418
|
+
const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(label); });
|
|
1419
|
+
server.on('connection', () => { state.connections += 1; });
|
|
1420
|
+
return new Promise((resolve, reject) => {
|
|
1421
|
+
server.once('error', reject);
|
|
1422
|
+
server.listen(0, '127.0.0.1', () => resolve({ server, state, port: server.address().port }));
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
async function probeNetworkMediation(backend) {
|
|
1427
|
+
if (backend !== 'userspace') return unsupported(`mediated network access is not implemented on the ${backend} backend (its network namespace has no path to a proxy)`);
|
|
1428
|
+
const root = mk('agsec-cap-n-');
|
|
1429
|
+
let a; let b; let proxy;
|
|
1430
|
+
try {
|
|
1431
|
+
a = await listener('A'); b = await listener('B');
|
|
1432
|
+
const built = bindManifest({
|
|
1433
|
+
schema: 'agentic-security/capability-manifest', schemaVersion: '1.0.0', taskId: 'probe-network', repository: { revision: ZERO_REV }, policyVersion: 1,
|
|
1434
|
+
network: [{ host: '127.0.0.1', port: a.port, schemes: ['http'] }],
|
|
1435
|
+
});
|
|
1436
|
+
if (!built.ok) return notProved('probe manifest was invalid');
|
|
1437
|
+
proxy = await startMediationProxy({ bound: built.bound });
|
|
1438
|
+
const script = `
|
|
1439
|
+
const http=require('http'),net=require('net');
|
|
1440
|
+
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()});
|
|
1441
|
+
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)});
|
|
1442
|
+
(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})}))})();`;
|
|
1443
|
+
const r = await runConfinedSupervised([NODE, '-e', script], {
|
|
1444
|
+
root, readRoots: [NODE], networkProxyPort: proxy.port, timeoutMs: 15000, graceMs: 300,
|
|
1445
|
+
});
|
|
1446
|
+
let out;
|
|
1447
|
+
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})`); }
|
|
1448
|
+
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');
|
|
1449
|
+
if (!(out.pb && out.pb.s === 403)) return notProved('an undeclared destination was not refused by the proxy');
|
|
1450
|
+
if (out.da?.connected || out.db?.connected) return notProved('a direct socket bypassed the proxy');
|
|
1451
|
+
if (b.state.connections !== 0) return notProved('the undeclared listener saw a connection');
|
|
1452
|
+
if (a.state.connections !== 1) return notProved(`the declared listener saw ${a.state.connections} connections; only the proxied request should have arrived`);
|
|
1453
|
+
return proved('declared destination reachable only through the proxy; undeclared refused by the proxy; direct sockets refused by the operating system');
|
|
1454
|
+
} finally {
|
|
1455
|
+
if (proxy) await proxy.close();
|
|
1456
|
+
for (const l of [a, b]) if (l) await new Promise((res) => l.server.close(() => res()));
|
|
1457
|
+
rm(root);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const _cache = new Map();
|
|
1462
|
+
function resetCapabilityProbeCache() { _cache.clear(); }
|
|
1463
|
+
|
|
1464
|
+
async function measure(backend, force) {
|
|
1465
|
+
const base = await probeControls({ force });
|
|
1466
|
+
const controls = { ...base.controls };
|
|
1467
|
+
if (backend === 'disabled') {
|
|
1468
|
+
for (const c of CAPABILITY_CONTROLS) controls[c] = { state: 'unavailable', reason: 'no confinement backend works on this host' };
|
|
1469
|
+
} else {
|
|
1470
|
+
const run = async (name, fn) => {
|
|
1471
|
+
try { controls[name] = await fn(); } catch (e) { controls[name] = notProved(`probe threw: ${String(e.message).split('\n')[0]}`); }
|
|
1472
|
+
};
|
|
1473
|
+
await run('fs-read-confinement', () => probeFsRead(backend));
|
|
1474
|
+
await run('fs-multi-root-write', () => probeMultiWrite(backend));
|
|
1475
|
+
await run('network-mediation', () => probeNetworkMediation(backend));
|
|
1476
|
+
}
|
|
1477
|
+
return controls;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Probe every control capability enforcement uses, on the active backend.
|
|
1482
|
+
* @param {object} [o]
|
|
1483
|
+
* @param {string} [o.force] backend override (same meaning as runConfined's)
|
|
1484
|
+
* @param {object} [o.probes] per-control overrides (test seam). An override can only
|
|
1485
|
+
* be used to make a control LESS proved in a test; a run
|
|
1486
|
+
* still goes through the real backend.
|
|
1487
|
+
*/
|
|
1488
|
+
async function probes_probeCapabilityControls({ force, probes = {} } = {}) {
|
|
1489
|
+
const backend = detectBackend({ force });
|
|
1490
|
+
if (!_cache.has(backend)) _cache.set(backend, await measure(backend, force));
|
|
1491
|
+
const controls = { ..._cache.get(backend) };
|
|
1492
|
+
// Overrides replace one control's result after the real measurement; the run
|
|
1493
|
+
// itself still goes through the real backend.
|
|
1494
|
+
for (const [name, fn] of Object.entries(probes)) controls[name] = await fn();
|
|
1495
|
+
const states = Object.fromEntries(Object.entries(controls).sort().map(([k, v]) => [k, v.state]));
|
|
1496
|
+
return {
|
|
1497
|
+
platform: process.platform, backend, controls,
|
|
1498
|
+
probeDigest: digestOf({ platform: process.platform, backend, states }),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Controls this manifest actually depends on; a control it never uses cannot block it. */
|
|
1503
|
+
function probes_requiredControlsFor(manifest) {
|
|
1504
|
+
const req = ['write-confinement', 'read-denial', 'fs-read-confinement', 'fs-multi-root-write', 'env-scrub', 'tree-termination', 'file-size-limit', 'network'];
|
|
1505
|
+
if (manifest.network.length) req.push('network-mediation');
|
|
1506
|
+
return req;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Whether this platform and backend are one the product ADVERTISES for enforced
|
|
1511
|
+
* mode. Taken from the feature table in the assurance config (Linux only), and
|
|
1512
|
+
* the only advertised backend is the kernel-namespace one. The macOS userspace
|
|
1513
|
+
* backend can be proved on a host by the probes above, and is still not
|
|
1514
|
+
* advertised: it is development-host evidence, not an enforced backend.
|
|
1515
|
+
*/
|
|
1516
|
+
function isAdvertisedBackend(backend, platform = process.platform) {
|
|
1517
|
+
return FEATURES['capability-enforcement'].platforms.includes(platform) && backend === 'namespace';
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Standing platform statements. Nothing here is measured; it states what has and has not been evidenced. */
|
|
1521
|
+
function platformStatements() {
|
|
1522
|
+
return Object.freeze({
|
|
1523
|
+
linux: {
|
|
1524
|
+
backend: 'namespace', status: 'unverified',
|
|
1525
|
+
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.',
|
|
1526
|
+
},
|
|
1527
|
+
darwin: {
|
|
1528
|
+
backend: 'userspace', status: 'host-proved-not-advertised',
|
|
1529
|
+
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.',
|
|
1530
|
+
},
|
|
1531
|
+
win32: { backend: null, status: 'unsupported', note: 'no isolation backend exists on Windows.' },
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
;// CONCATENATED MODULE: ./src/capabilities/recovery.js
|
|
1538
|
+
// Explicit policy recovery (X-506).
|
|
1539
|
+
//
|
|
1540
|
+
// A denied task does not talk its way out of a denial and does not retry until
|
|
1541
|
+
// something gives. The only way a denial turns into a permission is a policy
|
|
1542
|
+
// change that
|
|
1543
|
+
//
|
|
1544
|
+
// is OPERATOR-ONLY the grant is an Ed25519 signature over the exact change;
|
|
1545
|
+
// the signing key lives in the signer domain, which a worker
|
|
1546
|
+
// cannot read (sandbox/trust-domains.js, the protected key
|
|
1547
|
+
// directory), so a worker cannot mint one and a forged
|
|
1548
|
+
// signature fails verification
|
|
1549
|
+
// is BOUND the signature covers the digest of the manifest it
|
|
1550
|
+
// changes AND the digest of the manifest it produces, the
|
|
1551
|
+
// task and both policy versions, so it is not a blank
|
|
1552
|
+
// cheque and cannot be replayed on another task or version
|
|
1553
|
+
// is BOUNDED a short validity window, a single use (nonce), and a
|
|
1554
|
+
// finite number of changes per task
|
|
1555
|
+
// is RECORDED every applied change is an entry in a hash-chained ledger
|
|
1556
|
+
// creates a NEW VERSION the policy version rises by one. Every earlier binding
|
|
1557
|
+
// is then stale (`binding-mismatch`) and every receipt for
|
|
1558
|
+
// the earlier digest is superseded (receipts.js)
|
|
1559
|
+
//
|
|
1560
|
+
// A denial itself returns `blocked`, the missing capability and a proposed
|
|
1561
|
+
// manifest change for a human to review (`proposeChange`). The proposal is data:
|
|
1562
|
+
// applying it needs the operator grant above. Repeated denials of one action hit
|
|
1563
|
+
// a finite limit and stay blocked (`createDenialGuard`); no path in this module
|
|
1564
|
+
// turns a denial into an allow, falls back to unrestricted execution or loops.
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
const GRANT_SCHEMA = 'agentic-security/policy-grant';
|
|
1578
|
+
const LEDGER_SCHEMA = 'agentic-security/policy-ledger';
|
|
1579
|
+
const DEFAULT_RETRY_LIMIT = 3;
|
|
1580
|
+
const MAX_RETRY_LIMIT = 10;
|
|
1581
|
+
const DEFAULT_TASK_DENIAL_BUDGET = 20;
|
|
1582
|
+
const MAX_TASK_DENIAL_BUDGET = 100;
|
|
1583
|
+
const MAX_GRANT_TTL_MS = (/* unused pure expression or super */ null && (60 * 60 * 1000));
|
|
1584
|
+
const DEFAULT_MAX_CHANGES = 5;
|
|
1585
|
+
const MAX_TRACKED = 1000;
|
|
1586
|
+
const GENESIS = '0'.repeat(64);
|
|
1587
|
+
|
|
1588
|
+
const sha = (v) => external_node_crypto_.createHash('sha256').update(typeof v === 'string' ? v : (0,evidence_bundle/* canonicalJson */.dj)(v)).digest('hex');
|
|
1589
|
+
const clean = (s) => typeof s === 'string' && !/[\u0000-\u001f\u007f]/.test(s) && !detectSecretShapes(s);
|
|
1590
|
+
|
|
1591
|
+
// ---------------------------------------------------------------- proposals
|
|
1592
|
+
|
|
1593
|
+
// Codes that name a structural refusal. A proposal for any of these would be an
|
|
1594
|
+
// invitation to widen the boundary around keys, labels, links and secrets.
|
|
1595
|
+
const NOT_PROPOSABLE = new Set([
|
|
1596
|
+
'binding-mismatch', 'unknown-action', 'invalid-manifest', 'scope-expansion', 'path-invalid', 'path-traversal',
|
|
1597
|
+
'symlink-escape', 'protected-path', 'executable-not-absolute', 'executable-unresolvable', 'executable-in-writable-root',
|
|
1598
|
+
'args-invalid', 'secret-in-argument', 'host-invalid', 'port-invalid', 'dns-private-address', 'dns-changed',
|
|
1599
|
+
'payload-too-large', 'payload-uninspectable', 'resource-limit-exceeded', 'retry-limit', 'task-halted', 'identity-spoofed',
|
|
1600
|
+
'identity-missing', 'tool-unclassified', 'allowed', 'delegation-depth',
|
|
1601
|
+
]);
|
|
1602
|
+
|
|
1603
|
+
function unproposable(decision, why) {
|
|
1604
|
+
return Object.freeze({
|
|
1605
|
+
proposable: false, why, missing: Object.freeze({ capability: decision.kind, code: decision.code, detail: decision.subject }),
|
|
1606
|
+
selfGrantable: false, requiresOperatorGrant: true, change: null,
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* The missing capability and a reviewable manifest change for one denial. The
|
|
1612
|
+
* change is the NARROWEST addition that would allow exactly this action (an exact
|
|
1613
|
+
* path, an exact argument array, one host and port). It is a proposal: a worker
|
|
1614
|
+
* can read it and cannot apply it.
|
|
1615
|
+
*/
|
|
1616
|
+
function proposeChange(bound, action, decision) {
|
|
1617
|
+
const base = { kind: decision?.kind ?? 'unknown', code: decision?.code ?? 'unknown-action', subject: decision?.subject ?? '' };
|
|
1618
|
+
if (!decision || decision.decision === 'allow') return unproposable({ ...base, code: 'allowed' }, 'the action was allowed; there is nothing to propose');
|
|
1619
|
+
if (NOT_PROPOSABLE.has(decision.code)) return unproposable(decision, `${reasonText(decision.code)}; no manifest change is proposed for this refusal`);
|
|
1620
|
+
let add = null; let risk = 'scope-addition';
|
|
1621
|
+
const a = action || {};
|
|
1622
|
+
switch (a.kind) {
|
|
1623
|
+
case 'filesystem-read':
|
|
1624
|
+
case 'filesystem-write':
|
|
1625
|
+
if (typeof a.path === 'string' && clean(a.path)) add = { filesystem: { [a.kind === 'filesystem-write' ? 'write' : 'read']: [a.path] } };
|
|
1626
|
+
break;
|
|
1627
|
+
case 'command':
|
|
1628
|
+
if (typeof a.executable === 'string' && clean(a.executable) && (a.args ?? []).every((x) => typeof x === 'string' && clean(x))) {
|
|
1629
|
+
const entry = { executable: a.executable, args: { mode: 'exact', values: [...(a.args ?? [])] } };
|
|
1630
|
+
if (decision.code === 'interpreter-blocked' || decision.code === 'interpreter-args-unpinned') { entry.interpreter = 'scoped'; risk = 'interpreter'; }
|
|
1631
|
+
add = { commands: [entry] };
|
|
1632
|
+
}
|
|
1633
|
+
break;
|
|
1634
|
+
case 'network':
|
|
1635
|
+
// A literal loopback, private, link-local or metadata address is never offered as a destination.
|
|
1636
|
+
if (typeof a.host === 'string' && clean(a.host) && Number.isInteger(a.port) && (() => {
|
|
1637
|
+
const h = address_normalizeHost(a.host);
|
|
1638
|
+
return h.ok && (h.kind === 'name' || address_classifyAddress(h.host) === 'public');
|
|
1639
|
+
})()) {
|
|
1640
|
+
add = { network: [{ host: a.host, port: a.port, schemes: [a.scheme === 'http' ? 'http' : 'https'] }] };
|
|
1641
|
+
}
|
|
1642
|
+
break;
|
|
1643
|
+
case 'tool':
|
|
1644
|
+
// Only a tool this build knows can be proposed; an unknown name is not a capability.
|
|
1645
|
+
if (typeof a.tool === 'string' && toolCapabilityFor(a.tool)) add = { tools: [a.tool] };
|
|
1646
|
+
break;
|
|
1647
|
+
case 'delegation':
|
|
1648
|
+
if (decision.code === 'delegation-not-allowed') add = { delegation: { allow: true, maxDepth: 1 } };
|
|
1649
|
+
break;
|
|
1650
|
+
default: break;
|
|
1651
|
+
}
|
|
1652
|
+
if (!add) return unproposable(decision, 'the request cannot be turned into a safe manifest addition');
|
|
1653
|
+
const change = Object.freeze({ add });
|
|
1654
|
+
return Object.freeze({
|
|
1655
|
+
proposable: true, why: null, risk,
|
|
1656
|
+
missing: Object.freeze({ capability: decision.kind, code: decision.code, detail: decision.subject }),
|
|
1657
|
+
selfGrantable: false, requiresOperatorGrant: true,
|
|
1658
|
+
nextPolicyVersion: bound.binding.policyVersion + 1,
|
|
1659
|
+
change, changeDigest: (0,identity/* digestOf */.ol)(change),
|
|
1660
|
+
review: `Add to task ${reasons_sanitizeSubject(bound.binding.taskId, 80)} at policy version ${bound.binding.policyVersion + 1}: ${reasons_sanitizeSubject(JSON.stringify(add), 300)}`,
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ---------------------------------------------------------------- denial guard
|
|
1665
|
+
|
|
1666
|
+
function actionKey(binding, action) {
|
|
1667
|
+
const a = action && typeof action === 'object' ? action : {};
|
|
1668
|
+
const material = { kind: a.kind ?? null, path: a.path ?? null, executable: a.executable ?? null, args: Array.isArray(a.args) ? a.args : null, host: a.host ?? null, port: a.port ?? null, scheme: a.scheme ?? null, tool: a.tool ?? null };
|
|
1669
|
+
return sha({ taskId: binding?.taskId ?? null, policyVersion: binding?.policyVersion ?? null, material });
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Bounded denial handling. `admit` is asked BEFORE the policy is evaluated; once
|
|
1674
|
+
* an action has been denied `retryLimit` times in one policy version, or the task
|
|
1675
|
+
* has been denied `taskBudget` times in all, the answer is a fixed refusal and the
|
|
1676
|
+
* action is not even evaluated again. A new policy version starts a new count (the
|
|
1677
|
+
* operator changed something, so a retry is no longer the same request).
|
|
1678
|
+
*/
|
|
1679
|
+
function createDenialGuard({ retryLimit = DEFAULT_RETRY_LIMIT, taskBudget = DEFAULT_TASK_DENIAL_BUDGET } = {}) {
|
|
1680
|
+
const rl = Number.isInteger(retryLimit) && retryLimit >= 1 ? Math.min(retryLimit, MAX_RETRY_LIMIT) : DEFAULT_RETRY_LIMIT;
|
|
1681
|
+
const tb = Number.isInteger(taskBudget) && taskBudget >= 1 ? Math.min(taskBudget, MAX_TASK_DENIAL_BUDGET) : DEFAULT_TASK_DENIAL_BUDGET;
|
|
1682
|
+
const perAction = new Map();
|
|
1683
|
+
const perTask = new Map();
|
|
1684
|
+
const taskKey = (b) => `${b?.taskId ?? 'unbound'}@${b?.policyVersion ?? 0}`;
|
|
1685
|
+
return Object.freeze({
|
|
1686
|
+
retryLimit: rl, taskBudget: tb,
|
|
1687
|
+
admit(binding, action) {
|
|
1688
|
+
const t = perTask.get(taskKey(binding)) ?? 0;
|
|
1689
|
+
if (t >= tb || perAction.size >= MAX_TRACKED) return { admit: false, code: 'task-halted' };
|
|
1690
|
+
if ((perAction.get(actionKey(binding, action)) ?? 0) >= rl) return { admit: false, code: 'retry-limit' };
|
|
1691
|
+
return { admit: true, code: null };
|
|
1692
|
+
},
|
|
1693
|
+
record(binding, action) {
|
|
1694
|
+
const k = actionKey(binding, action);
|
|
1695
|
+
const n = (perAction.get(k) ?? 0) + 1;
|
|
1696
|
+
perAction.set(k, n);
|
|
1697
|
+
perTask.set(taskKey(binding), (perTask.get(taskKey(binding)) ?? 0) + 1);
|
|
1698
|
+
return { attempts: n, remaining: Math.max(0, rl - n), exhausted: n >= rl };
|
|
1699
|
+
},
|
|
1700
|
+
stats(binding) { return { taskDenials: perTask.get(taskKey(binding)) ?? 0, trackedActions: perAction.size }; },
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* One mediated decision with recovery semantics. The result is either the policy
|
|
1706
|
+
* allowing the action, or `blocked`; there is no third outcome, no retry inside
|
|
1707
|
+
* this function and no fallback.
|
|
1708
|
+
*/
|
|
1709
|
+
function mediate(bound, action, ctx, { guard } = {}) {
|
|
1710
|
+
const binding = ctx?.binding;
|
|
1711
|
+
if (guard) {
|
|
1712
|
+
const adm = guard.admit(binding, action);
|
|
1713
|
+
if (!adm.admit) {
|
|
1714
|
+
const decision = Object.freeze({
|
|
1715
|
+
decision: 'deny', code: adm.code, reason: reasonText(adm.code), kind: typeof action?.kind === 'string' ? reasons_sanitizeSubject(action.kind, 40) : 'unknown',
|
|
1716
|
+
subject: '(action not re-evaluated)', taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
|
|
1717
|
+
});
|
|
1718
|
+
return Object.freeze({ status: 'blocked', blocked: true, decision, ...proposalFields(unproposable(decision, reasonText(adm.code))), attemptsRemaining: 0, exhausted: true });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
const decision = decide_decide(bound, action, ctx);
|
|
1722
|
+
if (decision.decision === 'allow') return Object.freeze({ status: 'ok', blocked: false, decision, proposal: null, missing: null, attemptsRemaining: null, exhausted: false });
|
|
1723
|
+
const counted = guard ? guard.record(binding, action) : null;
|
|
1724
|
+
const proposal = counted?.exhausted ? unproposable(decision, `${reasonText('retry-limit')}`) : proposeChange(bound, action, decision);
|
|
1725
|
+
return Object.freeze({
|
|
1726
|
+
status: 'blocked', blocked: true, decision, ...proposalFields(proposal),
|
|
1727
|
+
attemptsRemaining: counted ? counted.remaining : null, exhausted: counted ? counted.exhausted : false,
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function proposalFields(p) { return { proposal: p, missing: p.missing }; }
|
|
1732
|
+
|
|
1733
|
+
// ---------------------------------------------------------------- operator grants
|
|
1734
|
+
|
|
1735
|
+
function assertSigner(domain) {
|
|
1736
|
+
if (!mayDo(domain, RESOURCES.SIGNING_KEY, 'read')) {
|
|
1737
|
+
throw Object.assign(new Error(`the ${String(domain)} domain may not sign a policy grant`), { code: 'domain-denied' });
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const ADD_KEYS = (/* unused pure expression or super */ null && (['filesystem', 'commands', 'network', 'tools', 'delegation']));
|
|
1742
|
+
|
|
1743
|
+
/** Apply an add-only change to a normalized manifest; the result is validated and bound at the next policy version. */
|
|
1744
|
+
function applyChangeToManifest(manifest, change) {
|
|
1745
|
+
if (!change || typeof change !== 'object' || Array.isArray(change) || Object.keys(change).some((k) => k !== 'add')) return { ok: false, code: 'change-invalid', bound: null };
|
|
1746
|
+
const add = change.add;
|
|
1747
|
+
if (!add || typeof add !== 'object' || Array.isArray(add) || Object.keys(add).length === 0 || Object.keys(add).some((k) => !ADD_KEYS.includes(k))) return { ok: false, code: 'change-invalid', bound: null };
|
|
1748
|
+
const next = JSON.parse(JSON.stringify(manifest));
|
|
1749
|
+
next.policyVersion = manifest.policyVersion + 1;
|
|
1750
|
+
if (add.filesystem) {
|
|
1751
|
+
if (typeof add.filesystem !== 'object' || Object.keys(add.filesystem).some((k) => !['read', 'write'].includes(k))) return { ok: false, code: 'change-invalid', bound: null };
|
|
1752
|
+
next.filesystem.read = [...next.filesystem.read, ...(add.filesystem.read ?? [])];
|
|
1753
|
+
next.filesystem.write = [...next.filesystem.write, ...(add.filesystem.write ?? [])];
|
|
1754
|
+
}
|
|
1755
|
+
if (add.commands) next.commands = [...next.commands, ...add.commands];
|
|
1756
|
+
if (add.network) next.network = [...next.network, ...add.network];
|
|
1757
|
+
if (add.tools) next.tools = [...next.tools, ...add.tools];
|
|
1758
|
+
if (add.delegation) next.delegation = add.delegation;
|
|
1759
|
+
const b = bindManifest(next);
|
|
1760
|
+
return b.ok ? { ok: true, code: null, bound: b.bound } : { ok: false, code: 'change-invalid', bound: null, errors: b.errors };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Operator side. Signs a grant for ONE change to ONE manifest. Refuses unless the
|
|
1765
|
+
* caller declares the signer domain; a worker or target cannot sign (and in any
|
|
1766
|
+
* case does not hold the key).
|
|
1767
|
+
*/
|
|
1768
|
+
function signPolicyGrant({ domain, privateKeyPem, bound, change, operator, reason, now = new Date(), ttlMs = 15 * 60 * 1000, nonce }) {
|
|
1769
|
+
assertSigner(domain);
|
|
1770
|
+
const applied = applyChangeToManifest(bound.manifest, change);
|
|
1771
|
+
if (!applied.ok) throw Object.assign(new Error('the change is not a valid add-only manifest change'), { code: applied.code });
|
|
1772
|
+
const ttl = Math.min(Math.max(1, ttlMs), MAX_GRANT_TTL_MS);
|
|
1773
|
+
const payload = {
|
|
1774
|
+
schema: GRANT_SCHEMA, taskId: bound.binding.taskId,
|
|
1775
|
+
fromPolicyVersion: bound.binding.policyVersion, fromDigest: bound.binding.digest,
|
|
1776
|
+
toPolicyVersion: applied.bound.binding.policyVersion, toDigest: applied.bound.binding.digest,
|
|
1777
|
+
operator: sanitizeSubject(operator, 80), reason: sanitizeSubject(reason, 200),
|
|
1778
|
+
issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + ttl).toISOString(),
|
|
1779
|
+
nonce: nonce ?? crypto.randomBytes(12).toString('hex'),
|
|
1780
|
+
};
|
|
1781
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
1782
|
+
const issuance = {
|
|
1783
|
+
issuer: { id: `local-install:${keyFingerprint(publicKeyPem).slice(0, 16)}`, kind: 'local-install', keyFingerprint: keyFingerprint(publicKeyPem) },
|
|
1784
|
+
trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
1785
|
+
};
|
|
1786
|
+
const sig = crypto.sign(null, Buffer.from(canonicalJson({ payload, issuance }), 'utf8'), privateKeyPem);
|
|
1787
|
+
return Object.freeze({ payload, issuance, signature: { algorithm: 'ed25519', value: sig.toString('base64') } });
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function verifyGrant(grant, publicKeyPem) {
|
|
1791
|
+
if (!grant || typeof grant !== 'object' || !grant.payload || !grant.signature || grant.payload.schema !== GRANT_SCHEMA) return { ok: false, code: 'grant-invalid' };
|
|
1792
|
+
if (Object.keys(grant).some((k) => !['payload', 'issuance', 'signature'].includes(k))) return { ok: false, code: 'grant-invalid' };
|
|
1793
|
+
if (grant.signature.algorithm !== 'ed25519' || typeof grant.signature.value !== 'string') return { ok: false, code: 'grant-invalid' };
|
|
1794
|
+
if (!publicKeyPem) return { ok: false, code: 'grant-unsigned' };
|
|
1795
|
+
let ok = false;
|
|
1796
|
+
try { ok = crypto.verify(null, Buffer.from(canonicalJson({ payload: grant.payload, issuance: grant.issuance }), 'utf8'), publicKeyPem, Buffer.from(grant.signature.value, 'base64')); } catch { ok = false; }
|
|
1797
|
+
if (!ok) return { ok: false, code: 'grant-signature-invalid' };
|
|
1798
|
+
const iss = grant.issuance;
|
|
1799
|
+
if (!iss || !TRUST_BASES[iss.trustBasis] || iss.independentlyCertified !== false || iss.issuer?.keyFingerprint !== keyFingerprint(publicKeyPem)) return { ok: false, code: 'grant-issuer-invalid' };
|
|
1800
|
+
return { ok: true, code: null };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// ---------------------------------------------------------------- ledger
|
|
1804
|
+
|
|
1805
|
+
/** A hash-chained record of every applied policy change. Held by the controller, never by a worker. */
|
|
1806
|
+
function createPolicyLedger({ maxChanges = DEFAULT_MAX_CHANGES } = {}) {
|
|
1807
|
+
const max = Number.isInteger(maxChanges) && maxChanges >= 1 ? Math.min(maxChanges, 50) : DEFAULT_MAX_CHANGES;
|
|
1808
|
+
const entries = [];
|
|
1809
|
+
const nonces = new Set();
|
|
1810
|
+
const current = new Map(); // taskId -> {policyVersion, digest}
|
|
1811
|
+
const head = () => (entries.length ? entries[entries.length - 1].hash : GENESIS);
|
|
1812
|
+
return {
|
|
1813
|
+
maxChanges: max,
|
|
1814
|
+
entries: () => entries.map((e) => ({ ...e })),
|
|
1815
|
+
changesFor: (taskId) => entries.filter((e) => e.taskId === taskId).length,
|
|
1816
|
+
nonceUsed: (n) => nonces.has(n),
|
|
1817
|
+
/** Seed the current policy of a task (the first bound manifest). */
|
|
1818
|
+
register(bound) { if (!current.has(bound.binding.taskId)) current.set(bound.binding.taskId, { policyVersion: bound.binding.policyVersion, digest: bound.binding.digest }); },
|
|
1819
|
+
current: (taskId) => current.get(taskId) ?? null,
|
|
1820
|
+
isCurrent: (binding) => { const c = current.get(binding?.taskId); return !!c && c.policyVersion === binding.policyVersion && c.digest === binding.digest; },
|
|
1821
|
+
append(entry) {
|
|
1822
|
+
const body = { seq: entries.length, prev: head(), ...entry };
|
|
1823
|
+
const full = { ...body, hash: sha(body) };
|
|
1824
|
+
entries.push(Object.freeze(full));
|
|
1825
|
+
nonces.add(entry.nonce);
|
|
1826
|
+
current.set(entry.taskId, { policyVersion: entry.toPolicyVersion, digest: entry.toDigest });
|
|
1827
|
+
return full;
|
|
1828
|
+
},
|
|
1829
|
+
verify() { return verifyLedgerEntries(entries); },
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function verifyLedgerEntries(list) {
|
|
1834
|
+
let prev = GENESIS;
|
|
1835
|
+
for (let i = 0; i < list.length; i++) {
|
|
1836
|
+
const { hash, ...body } = list[i];
|
|
1837
|
+
if (body.seq !== i || body.prev !== prev || sha(body) !== hash) return { ok: false, breakAt: i };
|
|
1838
|
+
prev = hash;
|
|
1839
|
+
}
|
|
1840
|
+
return { ok: true, breakAt: null, head: prev };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ---------------------------------------------------------------- applying a grant
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Apply an operator grant to a task's policy. Returns the NEW bound manifest (a
|
|
1847
|
+
* new policy version), the ledger entry and the re-run preflight. On any refusal
|
|
1848
|
+
* `ok` is false, `bound` is null and the old policy is untouched.
|
|
1849
|
+
*
|
|
1850
|
+
* @param {object} o
|
|
1851
|
+
* @param {{manifest:object,binding:object}} o.bound the current policy
|
|
1852
|
+
* @param {object} o.change the proposed `{add}`
|
|
1853
|
+
* @param {object} o.grant from `signPolicyGrant`
|
|
1854
|
+
* @param {string} o.publicKeyPem the operator's public key
|
|
1855
|
+
* @param {object} o.ledger from `createPolicyLedger`
|
|
1856
|
+
* @param {Date} [o.now]
|
|
1857
|
+
* @param {object} [o.deniedAction] re-decided under the new policy
|
|
1858
|
+
* @param {object} [o.probeReport] injected probe report (default: probe this host)
|
|
1859
|
+
*/
|
|
1860
|
+
async function applyPolicyChange({ bound, change, grant, publicKeyPem, ledger, now = new Date(), deniedAction, probeReport, ctxExtra = {} }) {
|
|
1861
|
+
const refuse = (code) => ({ ok: false, code, reason: `policy change refused: ${code}`, bound: null, entry: null, preflight: null });
|
|
1862
|
+
if (!bound?.manifest || !bound?.binding || !ledger) return refuse('invalid-input');
|
|
1863
|
+
const v = verifyGrant(grant, publicKeyPem);
|
|
1864
|
+
if (!v.ok) return refuse(v.code);
|
|
1865
|
+
const p = grant.payload;
|
|
1866
|
+
const issued = Date.parse(p.issuedAt); const expires = Date.parse(p.expiresAt);
|
|
1867
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires - issued > MAX_GRANT_TTL_MS || expires <= issued) return refuse('grant-window-invalid');
|
|
1868
|
+
if (now.getTime() < issued) return refuse('grant-not-yet-valid');
|
|
1869
|
+
if (now.getTime() >= expires) return refuse('grant-expired');
|
|
1870
|
+
const b = bound.binding;
|
|
1871
|
+
if (p.taskId !== b.taskId) return refuse('grant-wrong-task');
|
|
1872
|
+
if (p.fromPolicyVersion !== b.policyVersion || p.fromDigest !== b.digest) return refuse('grant-stale');
|
|
1873
|
+
ledger.register(bound);
|
|
1874
|
+
if (!ledger.isCurrent(b)) return refuse('grant-stale');
|
|
1875
|
+
if (ledger.nonceUsed(p.nonce)) return refuse('grant-replayed');
|
|
1876
|
+
if (ledger.changesFor(b.taskId) >= ledger.maxChanges) return refuse('change-limit');
|
|
1877
|
+
const applied = applyChangeToManifest(bound.manifest, change);
|
|
1878
|
+
if (!applied.ok) return refuse(applied.code);
|
|
1879
|
+
if (applied.bound.binding.digest !== p.toDigest || applied.bound.binding.policyVersion !== p.toPolicyVersion) return refuse('grant-change-mismatch');
|
|
1880
|
+
|
|
1881
|
+
const entry = ledger.append({
|
|
1882
|
+
taskId: b.taskId, operator: p.operator, reason: p.reason, nonce: p.nonce,
|
|
1883
|
+
fromPolicyVersion: b.policyVersion, fromDigest: b.digest, toPolicyVersion: p.toPolicyVersion, toDigest: p.toDigest,
|
|
1884
|
+
changeDigest: digestOf(change), at: now.toISOString(),
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
// Rerun what the change can affect: the manifest itself, the controls the new
|
|
1888
|
+
// manifest depends on, and the action that was denied.
|
|
1889
|
+
const nb = applied.bound;
|
|
1890
|
+
const pr = probeReport || await probeCapabilityControls({});
|
|
1891
|
+
const required = requiredControlsFor(nb.manifest);
|
|
1892
|
+
const unmet = unmetControls(pr, required);
|
|
1893
|
+
const valid = validateManifest(nb.manifest).ok;
|
|
1894
|
+
const redecision = deniedAction ? decide(nb, deniedAction, { binding: nb.binding, ...ctxExtra }) : null;
|
|
1895
|
+
const preflight = {
|
|
1896
|
+
manifestValid: valid, requiredControls: required, unmet,
|
|
1897
|
+
redecision: redecision ? { decision: redecision.decision, code: redecision.code } : null,
|
|
1898
|
+
ready: valid && unmet.length === 0 && (!redecision || redecision.decision === 'allow'),
|
|
1899
|
+
};
|
|
1900
|
+
return {
|
|
1901
|
+
ok: true, code: null, reason: 'policy changed; a new policy version was created', bound: nb, entry, preflight,
|
|
1902
|
+
supersedes: { policyVersion: b.policyVersion, digest: b.digest },
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
;// CONCATENATED MODULE: ./src/capabilities/records.js
|
|
1907
|
+
// Capability decisions as CORE-002 records, and the advisory (hook) view.
|
|
1908
|
+
//
|
|
1909
|
+
// A policy decision (decide.js) becomes an `agentic-security/capability-decision`
|
|
1910
|
+
// record. The record schema already carries the honesty rule this module relies
|
|
1911
|
+
// on: only runner or proxy mediation, naming a backend and the digest of an
|
|
1912
|
+
// active probe, may claim `enforced`. `enforced` here is true only when the
|
|
1913
|
+
// runner says the backend is an advertised one and every required control was
|
|
1914
|
+
// proved; a development-host run is recorded as a runner-mediated decision with
|
|
1915
|
+
// `enforced: false`.
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* @param {object} d a decision from `decide`
|
|
1923
|
+
* @param {object} o
|
|
1924
|
+
* @param {'runner'|'proxy'|'in-process-policy'|'hook-advisory'|'none'} o.mediation
|
|
1925
|
+
* @param {boolean} o.enforced
|
|
1926
|
+
* @param {string|null} o.backend
|
|
1927
|
+
* @param {string|null} o.probeDigest
|
|
1928
|
+
* @returns {{ok: boolean, record: object|null, errors: object[]}}
|
|
1929
|
+
*/
|
|
1930
|
+
function toCapabilityDecisionRecord(d, { mediation, enforced, backend = null, probeDigest = null }) {
|
|
1931
|
+
const rec = {
|
|
1932
|
+
schema: 'agentic-security/capability-decision', schemaVersion: schema_kit/* SCHEMA_VERSION */.f$,
|
|
1933
|
+
taskId: d.taskId ?? 'unbound', capability: d.kind, subject: reasons_sanitizeSubject(d.subject, 200),
|
|
1934
|
+
decision: d.decision, mediation, enforced: enforced === true, backend,
|
|
1935
|
+
...(probeDigest ? { probeDigest } : {}),
|
|
1936
|
+
reason: `${d.code}: ${d.reason}`,
|
|
1937
|
+
};
|
|
1938
|
+
rec.id = (0,contracts/* capabilityDecisionId */.A9)(rec);
|
|
1939
|
+
const v = (0,contracts/* validateCapabilityDecision */.f_)(rec);
|
|
1940
|
+
return v.ok ? { ok: true, record: Object.freeze(rec), errors: [] } : { ok: false, record: null, errors: v.errors };
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* What a hook may say about an action. The same policy answer, labelled as
|
|
1945
|
+
* advice: mediation `hook-advisory` and never enforced, however the manifest
|
|
1946
|
+
* reads. A shell command string is not an action the policy can parse, so it is
|
|
1947
|
+
* reported as unsupported for enforcement and left to the runner's structured
|
|
1948
|
+
* execution.
|
|
1949
|
+
*/
|
|
1950
|
+
function advise(bound, action, ctx) {
|
|
1951
|
+
if (action && action.kind === 'command' && typeof action.command === 'string') {
|
|
1952
|
+
return Object.freeze({
|
|
1953
|
+
advisory: true, enforced: false, decision: 'unsupported', code: 'interpreter-blocked',
|
|
1954
|
+
note: 'a shell command string cannot be mediated; run it as an executable and an argument array through the capability runner',
|
|
1955
|
+
record: null,
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
const d = decide(bound, action, ctx);
|
|
1959
|
+
const r = toCapabilityDecisionRecord(d, { mediation: 'hook-advisory', enforced: false, backend: null });
|
|
1960
|
+
return Object.freeze({
|
|
1961
|
+
advisory: true, enforced: false, decision: d.decision, code: d.code,
|
|
1962
|
+
note: 'advice only: a hook response explains a decision, it does not enforce one', record: r.ok ? r.record : null,
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
;// CONCATENATED MODULE: ./src/capabilities/tool-gate.js
|
|
1967
|
+
// The MCP tool gate (X-505.AC01, X-505.AC03).
|
|
1968
|
+
//
|
|
1969
|
+
// Every tool call is checked against the SAME decision function the runner and
|
|
1970
|
+
// the hooks use (`decide`), for the CURRENT task identity, before its handler
|
|
1971
|
+
// runs. The identity is the one the operator bound to the server; nothing the
|
|
1972
|
+
// calling agent sends can change it: tool arguments are closed schemas, and an
|
|
1973
|
+
// identity named in the request metadata is compared with the bound one and
|
|
1974
|
+
// refused when it differs (`identity-spoofed`).
|
|
1975
|
+
//
|
|
1976
|
+
// no policy, feature off the gate is inactive: behaviour is exactly what it
|
|
1977
|
+
// was before this module existed
|
|
1978
|
+
// feature on, no policy read tools pass; a mutating or externally
|
|
1979
|
+
// communicating tool is refused (`identity-missing`),
|
|
1980
|
+
// because there is no task to check it for
|
|
1981
|
+
// policy bound every tool is checked: it must be a declared tool
|
|
1982
|
+
// action (deny by default), a mutating one must also
|
|
1983
|
+
// have the session root inside a declared write root,
|
|
1984
|
+
// and a tool missing from the classification table is
|
|
1985
|
+
// refused (`tool-unclassified`)
|
|
1986
|
+
//
|
|
1987
|
+
// A denial is synchronous and final for that call: no prompt, no waiting, no
|
|
1988
|
+
// retry inside the gate. It carries the missing capability and a reviewable
|
|
1989
|
+
// proposed change (recovery.js); repeated denials hit the finite limit there.
|
|
1990
|
+
//
|
|
1991
|
+
// This is POLICY at the tool boundary. The tool then runs in the MCP server
|
|
1992
|
+
// process, so the decision is recorded as `in-process-policy` and never
|
|
1993
|
+
// `enforced`; the runner is the only enforcement layer.
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
const FEATURE = 'capability-enforcement';
|
|
2001
|
+
const MAX_KEPT = 500;
|
|
2002
|
+
|
|
2003
|
+
function synthetic(bound, kind, code, subject) {
|
|
2004
|
+
return Object.freeze({
|
|
2005
|
+
decision: 'deny', code, reason: reasonText(code), kind, subject: reasons_sanitizeSubject(subject),
|
|
2006
|
+
taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** Whether the OPERATOR enabled the feature (environment only; a project file can never enable it). */
|
|
2011
|
+
function featureEnabledByOperator(env = process.env) {
|
|
2012
|
+
try { return (0,config.resolveAssuranceConfig)({ env }).features?.[FEATURE]?.enabled === true; } catch { return false; }
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* @param {object} o
|
|
2017
|
+
* @param {string} o.sessionRoot
|
|
2018
|
+
* @param {{bound: object, binding: object}|null} [o.policy] the task's bound manifest and the identity it acts for
|
|
2019
|
+
* @param {object} [o.guard] denial guard (default: a fresh one with the default limits)
|
|
2020
|
+
* @param {object} [o.env]
|
|
2021
|
+
*/
|
|
2022
|
+
function createToolGate({ sessionRoot, policy = null, guard, env = process.env } = {}) {
|
|
2023
|
+
let current = policy && policy.bound && policy.binding ? { bound: policy.bound, binding: policy.binding } : null;
|
|
2024
|
+
const denialGuard = guard || createDenialGuard();
|
|
2025
|
+
const kept = [];
|
|
2026
|
+
|
|
2027
|
+
function record(decision) {
|
|
2028
|
+
const r = toCapabilityDecisionRecord(decision, { mediation: 'in-process-policy', enforced: false, backend: null });
|
|
2029
|
+
if (r.ok) { kept.push(r.record); if (kept.length > MAX_KEPT) kept.shift(); }
|
|
2030
|
+
return r.ok ? r.record : null;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function refused(d) {
|
|
2034
|
+
return Object.freeze({
|
|
2035
|
+
allowed: false, blocked: true, code: d.code, reason: d.reason, decision: d,
|
|
2036
|
+
missing: Object.freeze({ capability: 'tool', code: d.code, detail: d.subject }),
|
|
2037
|
+
proposal: null, attemptsRemaining: null, exhausted: false, record: record(d),
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function blockedResult(m, extra = {}) {
|
|
2042
|
+
return Object.freeze({
|
|
2043
|
+
allowed: false, blocked: true, code: m.decision.code, reason: m.decision.reason, decision: m.decision, missing: m.missing,
|
|
2044
|
+
proposal: m.proposal, attemptsRemaining: m.attemptsRemaining, exhausted: m.exhausted, record: record(m.decision), ...extra,
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const self = {
|
|
2049
|
+
/** The gate is active when a policy is bound or the operator turned the feature on. */
|
|
2050
|
+
active: () => current !== null || featureEnabledByOperator(env),
|
|
2051
|
+
hasPolicy: () => current !== null,
|
|
2052
|
+
/** Operator-side: replace the bound policy (a new policy version, a different task). Stale bindings are then refused. */
|
|
2053
|
+
update(next) { current = next && next.bound && next.binding ? { bound: next.bound, binding: next.binding } : null; },
|
|
2054
|
+
decisions: () => kept.slice(),
|
|
2055
|
+
guard: denialGuard,
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* @param {string} name the tool the agent asked for
|
|
2059
|
+
* @param {{claimedTaskId?: string}} [req] identity claimed in the request, if any
|
|
2060
|
+
*/
|
|
2061
|
+
check(name, { claimedTaskId } = {}) {
|
|
2062
|
+
if (!self.active()) return Object.freeze({ allowed: true, skipped: true, decision: null });
|
|
2063
|
+
if (!current) {
|
|
2064
|
+
if (!isMutatingOrExternal(name)) return Object.freeze({ allowed: true, skipped: true, decision: null });
|
|
2065
|
+
return refused(synthetic(null, 'tool', 'identity-missing', String(name)));
|
|
2066
|
+
}
|
|
2067
|
+
const { bound, binding } = current;
|
|
2068
|
+
if (claimedTaskId !== undefined && claimedTaskId !== binding.taskId) {
|
|
2069
|
+
return refused(synthetic(bound, 'tool', 'identity-spoofed', `${String(name)} (claimed task ${reasons_sanitizeSubject(claimedTaskId, 60)})`));
|
|
2070
|
+
}
|
|
2071
|
+
const cap = toolCapabilityFor(name);
|
|
2072
|
+
if (!cap) return refused(synthetic(bound, 'tool', 'tool-unclassified', String(name)));
|
|
2073
|
+
const ctx = { binding };
|
|
2074
|
+
const decisions = [];
|
|
2075
|
+
for (const req of cap.requires) {
|
|
2076
|
+
const action = req === 'tool' ? { kind: 'tool', tool: name } : { kind: 'filesystem-write', path: sessionRoot };
|
|
2077
|
+
const m = mediate(bound, action, ctx, { guard: denialGuard });
|
|
2078
|
+
if (m.status !== 'ok') return blockedResult(m, { checked: decisions.length });
|
|
2079
|
+
decisions.push(m.decision); record(m.decision);
|
|
2080
|
+
}
|
|
2081
|
+
return Object.freeze({ allowed: true, skipped: false, decision: decisions[0], decisions });
|
|
2082
|
+
},
|
|
2083
|
+
};
|
|
2084
|
+
return Object.freeze(self);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
/***/ }),
|
|
2089
|
+
|
|
5
2090
|
/***/ 69390:
|
|
6
2091
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
2092
|
|
|
@@ -335,6 +2420,179 @@ const dataflow_get_flow = {
|
|
|
335
2420
|
};
|
|
336
2421
|
|
|
337
2422
|
|
|
2423
|
+
/***/ }),
|
|
2424
|
+
|
|
2425
|
+
/***/ 82407:
|
|
2426
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2427
|
+
|
|
2428
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2429
|
+
/* harmony export */ _: () => (/* binding */ makeInvariantTools)
|
|
2430
|
+
/* harmony export */ });
|
|
2431
|
+
/* harmony import */ var _posture_invariants_export_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(90145);
|
|
2432
|
+
/* harmony import */ var _posture_assurance_config_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(90385);
|
|
2433
|
+
/* harmony import */ var _redact_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(83468);
|
|
2434
|
+
// invariant-tools.js: the MCP surface for bounded business-logic scenarios (X-407.AC03).
|
|
2435
|
+
//
|
|
2436
|
+
// One READ-ONLY tool, `invariant_scenario_export`: build the reproducible scenario package for a reviewer-supplied contract and a
|
|
2437
|
+
// disposable fixture directory that both live inside the session root. It runs nothing and writes nothing. The same function
|
|
2438
|
+
// the CLI calls (`posture/invariants/export.js` `exportFromFiles`) does the work, so the two surfaces cannot disagree.
|
|
2439
|
+
//
|
|
2440
|
+
// Guarantees, each with a test:
|
|
2441
|
+
// - paths are confined to the session root by the server's own `_confine` (passed in; this file never resolves a path itself);
|
|
2442
|
+
// - the export carries no tenant secret: scenarios holding secret-looking content are withheld, the fixture source is never
|
|
2443
|
+
// included, and the serialized result is checked once more with the server's own secret-shape redactor, which BLOCKS the
|
|
2444
|
+
// result rather than editing it (an edited scenario is a different scenario);
|
|
2445
|
+
// - it never claims exhaustive correctness: every export carries the bounded-sample statement;
|
|
2446
|
+
// - gated behind the `invariant-scenarios` feature (off by default, operator-only): with it off the tool reports it is
|
|
2447
|
+
// disabled and builds nothing.
|
|
2448
|
+
// The factory shape (rather than importing `_confine` from tools.js) avoids a cycle between this file and the registry.
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
|
|
2453
|
+
const MAX_RESPONSE_BYTES = 1_500_000;
|
|
2454
|
+
|
|
2455
|
+
function makeInvariantTools({ confine, META }) {
|
|
2456
|
+
const invariant_scenario_export = {
|
|
2457
|
+
name: 'invariant_scenario_export',
|
|
2458
|
+
description: 'Export the bounded stateful scenarios for one executable business-logic invariant as a reproducible package: scenario documents with content digests, the pinned fixture digest, replay manifests (when a commit is given), the bounds applied and the scenario families that could not be built. Read-only: runs nothing. The fixture source and any secret-looking content are never included, and the package states that it is a bounded sample, not a proof of correctness. Requires the invariant-scenarios feature (off by default).',
|
|
2459
|
+
inputSchema: {
|
|
2460
|
+
type: 'object',
|
|
2461
|
+
additionalProperties: false,
|
|
2462
|
+
properties: {
|
|
2463
|
+
invariant_file: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2464
|
+
fixture_dir: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2465
|
+
ledger_file: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2466
|
+
commit: { type: 'string', pattern: '^[0-9a-f]{40}([0-9a-f]{24})?$' },
|
|
2467
|
+
seed: { type: 'integer', minimum: 0, maximum: 4294967295 },
|
|
2468
|
+
bounds: {
|
|
2469
|
+
type: 'object', additionalProperties: false,
|
|
2470
|
+
properties: {
|
|
2471
|
+
actors: { type: 'integer', minimum: 1, maximum: 4 }, depth: { type: 'integer', minimum: 1, maximum: 12 }, requests: { type: 'integer', minimum: 1, maximum: 16 },
|
|
2472
|
+
scenarios: { type: 'integer', minimum: 1, maximum: 6 }, timeBudgetMs: { type: 'integer', minimum: 1, maximum: 8000 },
|
|
2473
|
+
},
|
|
2474
|
+
},
|
|
2475
|
+
},
|
|
2476
|
+
required: ['invariant_file', 'fixture_dir'],
|
|
2477
|
+
},
|
|
2478
|
+
async handler(args, ctx) {
|
|
2479
|
+
let invariantPath; let fixturePath; let ledgerPath;
|
|
2480
|
+
try {
|
|
2481
|
+
invariantPath = confine(ctx.sessionRoot, args.invariant_file, 'invariant_file');
|
|
2482
|
+
fixturePath = confine(ctx.sessionRoot, args.fixture_dir, 'fixture_dir');
|
|
2483
|
+
if (args.ledger_file) ledgerPath = confine(ctx.sessionRoot, args.ledger_file, 'ledger_file');
|
|
2484
|
+
} catch (e) {
|
|
2485
|
+
return { _meta: META, ok: false, status: 'rejected', reason: `path refused: ${String(e.message).replace(ctx.sessionRoot, '<root>')}` };
|
|
2486
|
+
}
|
|
2487
|
+
const config = (0,_posture_assurance_config_js__WEBPACK_IMPORTED_MODULE_1__.resolveAssuranceConfig)({ scanRoot: ctx.sessionRoot, env: process.env });
|
|
2488
|
+
const r = (0,_posture_invariants_export_js__WEBPACK_IMPORTED_MODULE_0__/* .exportFromFiles */ .iF)({ invariantPath, fixturePath, ledgerPath, commit: args.commit, seed: args.seed, bounds: args.bounds, config });
|
|
2489
|
+
if (r.status !== 'ok') return { _meta: META, ok: false, status: r.status, reason: String(r.reason || '').replace(ctx.sessionRoot, '<root>').slice(0, 400), withheld: r.withheld ?? [] };
|
|
2490
|
+
const text = JSON.stringify(r.export);
|
|
2491
|
+
if ((0,_redact_js__WEBPACK_IMPORTED_MODULE_2__/* .redactSecretShapes */ .Kb)(text).redactions > 0) return { _meta: META, ok: false, status: 'blocked', reason: 'the export contains secret-shaped content and was not returned; an edited scenario would be a different scenario' };
|
|
2492
|
+
if (text.length > MAX_RESPONSE_BYTES) return { _meta: META, ok: false, status: 'blocked', reason: 'the export is too large for one response; lower the bounds' };
|
|
2493
|
+
return { _meta: META, ok: true, status: 'ok', export: r.export, withheld: r.withheld };
|
|
2494
|
+
},
|
|
2495
|
+
};
|
|
2496
|
+
return { invariant_scenario_export };
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
|
|
2500
|
+
/***/ }),
|
|
2501
|
+
|
|
2502
|
+
/***/ 99027:
|
|
2503
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2504
|
+
|
|
2505
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2506
|
+
/* harmony export */ _: () => (/* binding */ makePortfolioTools)
|
|
2507
|
+
/* harmony export */ });
|
|
2508
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
2509
|
+
/* harmony import */ var _posture_assurance_config_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(90385);
|
|
2510
|
+
/* harmony import */ var _posture_portfolio_work_units_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(90987);
|
|
2511
|
+
/* harmony import */ var _posture_portfolio_scheduler_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(34563);
|
|
2512
|
+
/* harmony import */ var _posture_portfolio_progress_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(67439);
|
|
2513
|
+
/* harmony import */ var _posture_portfolio_wording_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9886);
|
|
2514
|
+
/* harmony import */ var _redact_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(83468);
|
|
2515
|
+
// portfolio-tools.js: the MCP surface for portfolio progress and review queues (X-707).
|
|
2516
|
+
//
|
|
2517
|
+
// One READ-ONLY tool, `portfolio_progress`: the coverage-aware progress view of a durable portfolio store that lives inside the session
|
|
2518
|
+
// root. It reads the store (verified; a corrupt store is an error, never a reset), the scheduler ledger beside it, and optionally a
|
|
2519
|
+
// budgets file and a findings file, and writes nothing. The same function the CLI calls (`posture/portfolio/progress.js`
|
|
2520
|
+
// `buildProgressView`) does the work, so the two surfaces cannot disagree.
|
|
2521
|
+
//
|
|
2522
|
+
// Guarantees, each with a test:
|
|
2523
|
+
// - paths are confined to the session root by the server's own `_confine` (passed in; this file never resolves a path itself);
|
|
2524
|
+
// - the view separates verified, blocked/failed/stale, coverage, budget and pending review, keeps stale workers visible, and never
|
|
2525
|
+
// states that the portfolio passed (a finished controller is reported as finished);
|
|
2526
|
+
// - the serialized result is checked with the server's secret-shape redactor and BLOCKED rather than edited if anything would change;
|
|
2527
|
+
// - gated behind the `portfolio-assurance` feature (off by default): with it off the tool answers `status: 'disabled'` and reads nothing.
|
|
2528
|
+
// The factory shape (rather than importing `_confine` from tools.js) avoids a cycle between this file and the registry.
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
const MAX_RESPONSE_BYTES = 1_500_000;
|
|
2538
|
+
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
|
2539
|
+
|
|
2540
|
+
function readJson(file) {
|
|
2541
|
+
const st = node_fs__WEBPACK_IMPORTED_MODULE_0__.lstatSync(file);
|
|
2542
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_INPUT_BYTES) throw new Error('not a regular JSON file within the size limit');
|
|
2543
|
+
return JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(file, 'utf8'));
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function makePortfolioTools({ confine, META }) {
|
|
2547
|
+
const portfolio_progress = {
|
|
2548
|
+
name: 'portfolio_progress',
|
|
2549
|
+
description: 'Show the progress of a resumable multi-repository portfolio audit as a coverage-aware view: verified units, blocked/failed/stale units, per-repository coverage, remaining budget, pending human review, worker liveness (stale workers stay visible) and, when a findings file is given, aggregate findings deduplicated by stable identity with per-repository/environment evidence and separate affected releases. A finished controller is reported as finished, never as passed. Read-only; paths must be inside the session root. Disabled unless the portfolio-assurance feature is on.',
|
|
2550
|
+
inputSchema: {
|
|
2551
|
+
type: 'object',
|
|
2552
|
+
additionalProperties: false,
|
|
2553
|
+
properties: {
|
|
2554
|
+
store_file: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2555
|
+
budgets_file: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2556
|
+
findings_file: { type: 'string', minLength: 1, maxLength: 512 },
|
|
2557
|
+
blocking_severity: { type: 'string', enum: ['info', 'low', 'medium', 'high', 'critical'] },
|
|
2558
|
+
now: { type: 'integer', minimum: 0 },
|
|
2559
|
+
},
|
|
2560
|
+
required: ['store_file'],
|
|
2561
|
+
},
|
|
2562
|
+
async handler(args, ctx) {
|
|
2563
|
+
const config = (0,_posture_assurance_config_js__WEBPACK_IMPORTED_MODULE_1__.resolveAssuranceConfig)({ scanRoot: ctx.sessionRoot, env: process.env });
|
|
2564
|
+
const gate = (0,_posture_assurance_config_js__WEBPACK_IMPORTED_MODULE_1__/* .featureStatus */ .FX)(config, _posture_portfolio_wording_js__WEBPACK_IMPORTED_MODULE_5__/* .FEATURE_ID */ .DU);
|
|
2565
|
+
if (gate.status !== 'ok') return { _meta: META, ok: false, status: gate.status, reason: `portfolio progress is not available: ${gate.reason ?? gate.code}` };
|
|
2566
|
+
let storePath; let budgetsPath; let findingsPath;
|
|
2567
|
+
try {
|
|
2568
|
+
storePath = confine(ctx.sessionRoot, args.store_file, 'store_file');
|
|
2569
|
+
if (args.budgets_file) budgetsPath = confine(ctx.sessionRoot, args.budgets_file, 'budgets_file');
|
|
2570
|
+
if (args.findings_file) findingsPath = confine(ctx.sessionRoot, args.findings_file, 'findings_file');
|
|
2571
|
+
} catch (e) {
|
|
2572
|
+
return { _meta: META, ok: false, status: 'rejected', reason: `path refused: ${String(e.message).replace(ctx.sessionRoot, '<root>')}` };
|
|
2573
|
+
}
|
|
2574
|
+
let store; let ledger; let budgets = null; let findings = null;
|
|
2575
|
+
try {
|
|
2576
|
+
store = (0,_posture_portfolio_work_units_js__WEBPACK_IMPORTED_MODULE_2__/* .readStore */ .uz)(storePath);
|
|
2577
|
+
if (!store) return { _meta: META, ok: false, status: 'rejected', reason: 'no portfolio store at that path' };
|
|
2578
|
+
ledger = (0,_posture_portfolio_scheduler_js__WEBPACK_IMPORTED_MODULE_3__/* .readLedger */ .SC)(storePath);
|
|
2579
|
+
if (budgetsPath) budgets = readJson(budgetsPath);
|
|
2580
|
+
if (findingsPath) findings = readJson(findingsPath);
|
|
2581
|
+
} catch (e) {
|
|
2582
|
+
return { _meta: META, ok: false, status: 'blocked', reason: `the store or an input failed verification: ${String(e.code ?? e.message).slice(0, 200)}` };
|
|
2583
|
+
}
|
|
2584
|
+
const r = (0,_posture_portfolio_progress_js__WEBPACK_IMPORTED_MODULE_4__/* .buildProgressView */ .TJ)({ store, ledger, budgets, now: Number.isInteger(args.now) ? args.now : Date.now(), findings, blockingSeverity: args.blocking_severity });
|
|
2585
|
+
if (!r.ok) return { _meta: META, ok: false, status: 'rejected', reason: r.errors.map((e) => e.message).join('; ').slice(0, 400) };
|
|
2586
|
+
const text = JSON.stringify(r.view);
|
|
2587
|
+
if ((0,_redact_js__WEBPACK_IMPORTED_MODULE_6__/* .redactSecretShapes */ .Kb)(text).redactions > 0) return { _meta: META, ok: false, status: 'blocked', reason: 'the view contains secret-shaped content and was not returned' };
|
|
2588
|
+
if (text.length > MAX_RESPONSE_BYTES) return { _meta: META, ok: false, status: 'blocked', reason: 'the view is too large for one response' };
|
|
2589
|
+
return { _meta: META, ok: true, status: 'ok', view: r.view };
|
|
2590
|
+
},
|
|
2591
|
+
};
|
|
2592
|
+
return { portfolio_progress };
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
|
|
338
2596
|
/***/ }),
|
|
339
2597
|
|
|
340
2598
|
/***/ 27438:
|
|
@@ -350,8 +2608,9 @@ __webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependen
|
|
|
350
2608
|
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
351
2609
|
/* harmony import */ var node_url__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(73136);
|
|
352
2610
|
/* harmony import */ var _tools_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(14054);
|
|
353
|
-
/* harmony import */ var
|
|
2611
|
+
/* harmony import */ var _validate_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(61211);
|
|
354
2612
|
/* harmony import */ var _audit_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(69390);
|
|
2613
|
+
/* harmony import */ var _capabilities_tool_gate_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(99405);
|
|
355
2614
|
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_tools_js__WEBPACK_IMPORTED_MODULE_4__]);
|
|
356
2615
|
_tools_js__WEBPACK_IMPORTED_MODULE_4__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
357
2616
|
// MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
|
|
@@ -372,6 +2631,8 @@ _tools_js__WEBPACK_IMPORTED_MODULE_4__ = (__webpack_async_dependencies__.then ?
|
|
|
372
2631
|
|
|
373
2632
|
|
|
374
2633
|
|
|
2634
|
+
// X-505: tool calls are checked against the capability policy before a handler runs.
|
|
2635
|
+
|
|
375
2636
|
|
|
376
2637
|
const PROTOCOL_VERSION = '2025-03-26';
|
|
377
2638
|
const SERVER_NAME = 'agentic-security';
|
|
@@ -399,7 +2660,7 @@ const TOOLS_BY_NAME = Object.fromEntries(_tools_js__WEBPACK_IMPORTED_MODULE_4__/
|
|
|
399
2660
|
function _codeFingerprint() {
|
|
400
2661
|
try {
|
|
401
2662
|
const here = node_path__WEBPACK_IMPORTED_MODULE_2__.dirname((0,node_url__WEBPACK_IMPORTED_MODULE_3__.fileURLToPath)(import.meta.url));
|
|
402
|
-
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
2663
|
+
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'invariant-tools.js', 'portfolio-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
403
2664
|
const h = node_crypto__WEBPACK_IMPORTED_MODULE_1__.createHash('sha256');
|
|
404
2665
|
for (const f of files) {
|
|
405
2666
|
try { h.update(f); h.update(node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(here, f))); } catch {}
|
|
@@ -419,8 +2680,12 @@ function _ok(id, result) {
|
|
|
419
2680
|
return { jsonrpc: '2.0', id, result };
|
|
420
2681
|
}
|
|
421
2682
|
|
|
422
|
-
|
|
2683
|
+
// `capabilityPolicy` is `{ bound, binding }`, supplied by the operator from the
|
|
2684
|
+
// capability manifest facility. Absent, and with the `capability-enforcement`
|
|
2685
|
+
// feature off, the gate is inactive and behaviour is unchanged.
|
|
2686
|
+
function createServer({ sessionRoot = process.cwd(), capabilityPolicy = null } = {}) {
|
|
423
2687
|
const ctx = { sessionRoot };
|
|
2688
|
+
const gate = (0,_capabilities_tool_gate_js__WEBPACK_IMPORTED_MODULE_6__/* .createToolGate */ .e)({ sessionRoot, policy: capabilityPolicy });
|
|
424
2689
|
|
|
425
2690
|
async function handleRequest(msg) {
|
|
426
2691
|
if (!msg || typeof msg !== 'object') return _err(null, -32600, 'Invalid Request');
|
|
@@ -473,7 +2738,22 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
|
473
2738
|
(0,_audit_js__WEBPACK_IMPORTED_MODULE_5__.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
|
|
474
2739
|
return _err(id, -32602, `Unknown tool: ${name}`);
|
|
475
2740
|
}
|
|
476
|
-
|
|
2741
|
+
// X-505: the capability check, for the task identity bound to this server.
|
|
2742
|
+
// An identity named in the request metadata can only be compared with it.
|
|
2743
|
+
const gated = gate.check(name, { claimedTaskId: msg.params?._meta?.taskId });
|
|
2744
|
+
if (!gated.allowed) {
|
|
2745
|
+
(0,_audit_js__WEBPACK_IMPORTED_MODULE_5__.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `capability-denied: ${gated.code}` });
|
|
2746
|
+
const p = gated.proposal;
|
|
2747
|
+
return _ok(id, {
|
|
2748
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
2749
|
+
blocked: true, code: gated.code, reason: gated.reason, missing: gated.missing,
|
|
2750
|
+
proposedChange: p && p.proposable ? { review: p.review, change: p.change, nextPolicyVersion: p.nextPolicyVersion, requiresOperatorGrant: true } : null,
|
|
2751
|
+
attemptsRemaining: gated.attemptsRemaining, exhausted: gated.exhausted,
|
|
2752
|
+
}, null, 2) }],
|
|
2753
|
+
isError: true,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
try { (0,_validate_js__WEBPACK_IMPORTED_MODULE_7__/* .validate */ .t)(tool.inputSchema, args); }
|
|
477
2757
|
catch (e) {
|
|
478
2758
|
(0,_audit_js__WEBPACK_IMPORTED_MODULE_5__.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
|
|
479
2759
|
return _ok(id, {
|
|
@@ -503,7 +2783,7 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
|
503
2783
|
}
|
|
504
2784
|
}
|
|
505
2785
|
|
|
506
|
-
return { handleRequest, sessionRoot };
|
|
2786
|
+
return { handleRequest, sessionRoot, capabilityGate: gate };
|
|
507
2787
|
}
|
|
508
2788
|
|
|
509
2789
|
// NOTE: no default-singleton export. Callers must use createServer({...})
|
|
@@ -637,13 +2917,16 @@ __webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependen
|
|
|
637
2917
|
/* harmony import */ var _posture_integrity_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(71130);
|
|
638
2918
|
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(31174);
|
|
639
2919
|
/* harmony import */ var _posture_cache_economics_js__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(58752);
|
|
640
|
-
/* harmony import */ var
|
|
2920
|
+
/* harmony import */ var _redact_js__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(83468);
|
|
641
2921
|
/* harmony import */ var _report_index_js__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(75917);
|
|
642
2922
|
/* harmony import */ var _posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(51864);
|
|
643
|
-
/* harmony import */ var
|
|
644
|
-
/* harmony import */ var
|
|
645
|
-
/* harmony import */ var
|
|
646
|
-
/* harmony import */ var
|
|
2923
|
+
/* harmony import */ var _lineage_deployment_projection_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(73596);
|
|
2924
|
+
/* harmony import */ var _posture_provenance_schema_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(34594);
|
|
2925
|
+
/* harmony import */ var _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(20095);
|
|
2926
|
+
/* harmony import */ var _invariant_tools_js__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(82407);
|
|
2927
|
+
/* harmony import */ var _portfolio_tools_js__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(99027);
|
|
2928
|
+
/* harmony import */ var _posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(79907);
|
|
2929
|
+
/* harmony import */ var _posture_cve_lookup_js__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(71364);
|
|
647
2930
|
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_report_index_js__WEBPACK_IMPORTED_MODULE_12__]);
|
|
648
2931
|
_report_index_js__WEBPACK_IMPORTED_MODULE_12__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
649
2932
|
// MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
|
|
@@ -676,12 +2959,15 @@ _report_index_js__WEBPACK_IMPORTED_MODULE_12__ = (__webpack_async_dependencies__
|
|
|
676
2959
|
|
|
677
2960
|
// X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
|
|
678
2961
|
|
|
2962
|
+
|
|
679
2963
|
// Git-origin provenance (Finding Provenance M0/M1). Distinct from
|
|
680
2964
|
// `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
|
|
681
2965
|
// (Sigstore/SLSA attestation) — see report/index.js's import comment.
|
|
682
2966
|
|
|
683
2967
|
|
|
684
2968
|
|
|
2969
|
+
|
|
2970
|
+
|
|
685
2971
|
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
686
2972
|
// friends) that aren't available in the plugin-cache install path
|
|
687
2973
|
// (no node_modules). Deferring keeps the MCP server bootable everywhere;
|
|
@@ -1088,7 +3374,7 @@ const scan_diff = {
|
|
|
1088
3374
|
// scanning), left as-is rather than silently claimed fixed here.
|
|
1089
3375
|
const findings = (0,_report_index_js__WEBPACK_IMPORTED_MODULE_12__.normalizeFindings)(result.scan)
|
|
1090
3376
|
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
1091
|
-
.map(f => (0,
|
|
3377
|
+
.map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
|
|
1092
3378
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1093
3379
|
title: f.vuln, cwe: f.cwe,
|
|
1094
3380
|
description: f.description, remediation: f.remediation,
|
|
@@ -1151,7 +3437,7 @@ const query_taint = {
|
|
|
1151
3437
|
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
|
|
1152
3438
|
return hay.includes(srcL) && hay.includes(sinkL);
|
|
1153
3439
|
});
|
|
1154
|
-
const page = all.slice(off, off + lim).map(f => (0,
|
|
3440
|
+
const page = all.slice(off, off + lim).map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
|
|
1155
3441
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1156
3442
|
title: f.title || f.vuln, description: f.description,
|
|
1157
3443
|
trace: f.trace || null,
|
|
@@ -1189,7 +3475,7 @@ const explain_finding = {
|
|
|
1189
3475
|
if (!scan) throw new Error(`No usable scan state (${status}).`);
|
|
1190
3476
|
const f = _findById(scan, finding_id);
|
|
1191
3477
|
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
1192
|
-
const redacted = (0,
|
|
3478
|
+
const redacted = (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
|
|
1193
3479
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1194
3480
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
1195
3481
|
description: f.description, remediation: f.remediation,
|
|
@@ -1246,13 +3532,17 @@ const explain_finding = {
|
|
|
1246
3532
|
// logins/CODEOWNERS lines) through this MCP surface because this call
|
|
1247
3533
|
// passed no options object at all, silently defeating their policy at
|
|
1248
3534
|
// this one output boundary while report/index.js honoured it.
|
|
1249
|
-
findingProvenance: f.findingProvenance ? (0,
|
|
3535
|
+
findingProvenance: f.findingProvenance ? (0,_posture_provenance_schema_js__WEBPACK_IMPORTED_MODULE_15__/* .redactFindingProvenance */ .As)(f.findingProvenance, {
|
|
1250
3536
|
pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
|
|
1251
3537
|
}) : null,
|
|
1252
3538
|
// X-206: the same verification projection the JSON and text reports carry; absent when the finding has no record.
|
|
1253
3539
|
...(f.verificationRecord && typeof f.verificationRecord === 'object'
|
|
1254
3540
|
? { verificationRecord: f.verificationRecord, ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(f.verificationRecord, { replay: f.verificationReplay }) }
|
|
1255
3541
|
: {}),
|
|
3542
|
+
// X-307: the same boundary projection the JSON and text reports carry; absent when the finding has no context.
|
|
3543
|
+
...(f.boundaryContext && typeof f.boundaryContext === 'object'
|
|
3544
|
+
? (0,_lineage_deployment_projection_js__WEBPACK_IMPORTED_MODULE_14__/* .boundaryFields */ .Q)(f.boundaryContext)
|
|
3545
|
+
: {}),
|
|
1256
3546
|
};
|
|
1257
3547
|
},
|
|
1258
3548
|
};
|
|
@@ -1330,7 +3620,7 @@ const _scrubRoot = (sessionRoot, text) => {
|
|
|
1330
3620
|
const roots = new Set([node_path__WEBPACK_IMPORTED_MODULE_2__.resolve(sessionRoot)]);
|
|
1331
3621
|
try { roots.add(node_fs__WEBPACK_IMPORTED_MODULE_0__.realpathSync(node_path__WEBPACK_IMPORTED_MODULE_2__.resolve(sessionRoot))); } catch { /* root vanished: the plain path is still scrubbed */ }
|
|
1332
3622
|
for (const r of roots) out = out.split(r).join('<root>');
|
|
1333
|
-
return (0,
|
|
3623
|
+
return (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(out);
|
|
1334
3624
|
};
|
|
1335
3625
|
const _gateSummary = (g) => (g ? {
|
|
1336
3626
|
path: g.path ? g.path.ok : undefined, syntax: g.syntax ? g.syntax.ok : undefined,
|
|
@@ -1778,7 +4068,7 @@ const verify_fix = {
|
|
|
1778
4068
|
_meta: META,
|
|
1779
4069
|
ok: r.ok,
|
|
1780
4070
|
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
1781
|
-
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: (0,
|
|
4071
|
+
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(r.lint.output || '').slice(0, 1500) },
|
|
1782
4072
|
// verifyFix computes five legs, not two — tests/honesty/poc were
|
|
1783
4073
|
// being silently dropped here, leaving an agent with no structured
|
|
1784
4074
|
// way to see WHY verification failed when the failure was in one
|
|
@@ -1789,7 +4079,7 @@ const verify_fix = {
|
|
|
1789
4079
|
// it can echo proof-harness detail derived from scanned source.
|
|
1790
4080
|
tests: r.tests,
|
|
1791
4081
|
honesty: r.honesty,
|
|
1792
|
-
poc: r.poc ? { ...r.poc, reason: r.poc.reason ? (0,
|
|
4082
|
+
poc: r.poc ? { ...r.poc, reason: r.poc.reason ? (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(r.poc.reason) : r.poc.reason } : r.poc,
|
|
1793
4083
|
summary: r.summary,
|
|
1794
4084
|
// X-201: the one version-1 verification record (additive). It states what was actually established, e.g.
|
|
1795
4085
|
// `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
|
|
@@ -2137,7 +4427,7 @@ const append_agents_memory = {
|
|
|
2137
4427
|
required: ['agent', 'body'],
|
|
2138
4428
|
},
|
|
2139
4429
|
async handler({ agent, body }, ctx) {
|
|
2140
|
-
const r = (0,
|
|
4430
|
+
const r = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_20__/* .appendAgentsMemory */ .eu)(ctx.sessionRoot, { agent, body });
|
|
2141
4431
|
return { _meta: META, ...r };
|
|
2142
4432
|
},
|
|
2143
4433
|
};
|
|
@@ -2153,7 +4443,7 @@ const read_agents_memory = {
|
|
|
2153
4443
|
},
|
|
2154
4444
|
},
|
|
2155
4445
|
async handler({ full }, ctx) {
|
|
2156
|
-
const body = (0,
|
|
4446
|
+
const body = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_20__/* .readAgentsMemory */ .ox)(ctx.sessionRoot);
|
|
2157
4447
|
if (!body) return { _meta: META, present: false };
|
|
2158
4448
|
if (full) return { _meta: META, present: true, length: body.length, content: body };
|
|
2159
4449
|
// Tail-only — same logic as summarizeForSession but inlined to avoid a
|
|
@@ -2197,7 +4487,7 @@ const query_triage_memory = {
|
|
|
2197
4487
|
// they landed in, rather than hardcoding a field allowlist that could
|
|
2198
4488
|
// miss one.
|
|
2199
4489
|
let results;
|
|
2200
|
-
try { results = JSON.parse((0,
|
|
4490
|
+
try { results = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(JSON.stringify(raw))); }
|
|
2201
4491
|
catch { results = raw; }
|
|
2202
4492
|
return {
|
|
2203
4493
|
_meta: META,
|
|
@@ -2233,7 +4523,7 @@ const query_findings_memory = {
|
|
|
2233
4523
|
// whole-structure redactString round-trip is applied rather than a
|
|
2234
4524
|
// per-field allowlist that could miss one of the four shapes.
|
|
2235
4525
|
let body;
|
|
2236
|
-
try { body = JSON.parse((0,
|
|
4526
|
+
try { body = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(JSON.stringify(raw))); }
|
|
2237
4527
|
catch { body = raw; }
|
|
2238
4528
|
return { _meta: META, ...body };
|
|
2239
4529
|
},
|
|
@@ -2255,7 +4545,7 @@ const lookup_cve = {
|
|
|
2255
4545
|
required: ['cve'],
|
|
2256
4546
|
},
|
|
2257
4547
|
async handler({ cve }, _ctx) {
|
|
2258
|
-
const r = (0,
|
|
4548
|
+
const r = (0,_posture_cve_lookup_js__WEBPACK_IMPORTED_MODULE_21__/* .lookupCve */ .x)(cve);
|
|
2259
4549
|
return { _meta: META, ...r };
|
|
2260
4550
|
},
|
|
2261
4551
|
};
|
|
@@ -2360,7 +4650,12 @@ const apply_sca_upgrade = {
|
|
|
2360
4650
|
},
|
|
2361
4651
|
};
|
|
2362
4652
|
|
|
2363
|
-
|
|
4653
|
+
// X-407: read-only scenario export; the factory takes this file's confinement so the two modules do not import each other.
|
|
4654
|
+
const { invariant_scenario_export } = (0,_invariant_tools_js__WEBPACK_IMPORTED_MODULE_17__/* .makeInvariantTools */ ._)({ confine: _confine, META });
|
|
4655
|
+
// X-707: read-only portfolio progress view, same factory shape.
|
|
4656
|
+
const { portfolio_progress } = (0,_portfolio_tools_js__WEBPACK_IMPORTED_MODULE_18__/* .makePortfolioTools */ ._)({ confine: _confine, META });
|
|
4657
|
+
|
|
4658
|
+
const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_16__/* .dataflow_get_graph */ .HX, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_16__/* .dataflow_get_node */ .HI, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_16__/* .dataflow_get_edge */ .gC, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_16__/* .dataflow_get_flow */ .ri, invariant_scenario_export, portfolio_progress];
|
|
2364
4659
|
|
|
2365
4660
|
__webpack_async_result__();
|
|
2366
4661
|
} catch(e) { __webpack_async_result__(e); } });
|