@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -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 _validate_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(61211);
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
- function createServer({ sessionRoot = process.cwd() } = {}) {
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
- try { (0,_validate_js__WEBPACK_IMPORTED_MODULE_6__/* .validate */ .t)(tool.inputSchema, args); }
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,12 +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 _redact_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(83468);
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
- /* harmony import */ var _posture_provenance_schema_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(34594);
643
- /* harmony import */ var _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(20095);
644
- /* harmony import */ var _posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(79907);
645
- /* harmony import */ var _posture_cve_lookup_js__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(71364);
2922
+ /* harmony import */ var _posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(51864);
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);
646
2930
  var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_report_index_js__WEBPACK_IMPORTED_MODULE_12__]);
647
2931
  _report_index_js__WEBPACK_IMPORTED_MODULE_12__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
648
2932
  // MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
@@ -673,12 +2957,17 @@ _report_index_js__WEBPACK_IMPORTED_MODULE_12__ = (__webpack_async_dependencies__
673
2957
 
674
2958
 
675
2959
 
2960
+ // X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
2961
+
2962
+
676
2963
  // Git-origin provenance (Finding Provenance M0/M1). Distinct from
677
2964
  // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
678
2965
  // (Sigstore/SLSA attestation) — see report/index.js's import comment.
679
2966
 
680
2967
 
681
2968
 
2969
+
2970
+
682
2971
  // Lazy-loaded: these transitively pull in npm packages (@babel/core and
683
2972
  // friends) that aren't available in the plugin-cache install path
684
2973
  // (no node_modules). Deferring keeps the MCP server bootable everywhere;
@@ -1085,7 +3374,7 @@ const scan_diff = {
1085
3374
  // scanning), left as-is rather than silently claimed fixed here.
1086
3375
  const findings = (0,_report_index_js__WEBPACK_IMPORTED_MODULE_12__.normalizeFindings)(result.scan)
1087
3376
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
1088
- .map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactFinding */ .lE)({
3377
+ .map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
1089
3378
  id: f.id, severity: f.severity, file: f.file, line: f.line,
1090
3379
  title: f.vuln, cwe: f.cwe,
1091
3380
  description: f.description, remediation: f.remediation,
@@ -1148,7 +3437,7 @@ const query_taint = {
1148
3437
  const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
1149
3438
  return hay.includes(srcL) && hay.includes(sinkL);
1150
3439
  });
1151
- const page = all.slice(off, off + lim).map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactFinding */ .lE)({
3440
+ const page = all.slice(off, off + lim).map(f => (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
1152
3441
  id: f.id, severity: f.severity, file: f.file, line: f.line,
1153
3442
  title: f.title || f.vuln, description: f.description,
1154
3443
  trace: f.trace || null,
@@ -1186,7 +3475,7 @@ const explain_finding = {
1186
3475
  if (!scan) throw new Error(`No usable scan state (${status}).`);
1187
3476
  const f = _findById(scan, finding_id);
1188
3477
  if (!f) throw new Error(`Finding not found: ${finding_id}`);
1189
- const redacted = (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactFinding */ .lE)({
3478
+ const redacted = (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactFinding */ .lE)({
1190
3479
  id: f.id, severity: f.severity, file: f.file, line: f.line,
1191
3480
  title: f.title || f.vuln, cwe: f.cwe,
1192
3481
  description: f.description, remediation: f.remediation,
@@ -1243,9 +3532,17 @@ const explain_finding = {
1243
3532
  // logins/CODEOWNERS lines) through this MCP surface because this call
1244
3533
  // passed no options object at all, silently defeating their policy at
1245
3534
  // this one output boundary while report/index.js honoured it.
1246
- findingProvenance: f.findingProvenance ? (0,_posture_provenance_schema_js__WEBPACK_IMPORTED_MODULE_13__/* .redactFindingProvenance */ .As)(f.findingProvenance, {
3535
+ findingProvenance: f.findingProvenance ? (0,_posture_provenance_schema_js__WEBPACK_IMPORTED_MODULE_15__/* .redactFindingProvenance */ .As)(f.findingProvenance, {
1247
3536
  pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
1248
3537
  }) : null,
3538
+ // X-206: the same verification projection the JSON and text reports carry; absent when the finding has no record.
3539
+ ...(f.verificationRecord && typeof f.verificationRecord === 'object'
3540
+ ? { verificationRecord: f.verificationRecord, ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(f.verificationRecord, { replay: f.verificationReplay }) }
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
+ : {}),
1249
3546
  };
1250
3547
  },
1251
3548
  };
@@ -1323,7 +3620,7 @@ const _scrubRoot = (sessionRoot, text) => {
1323
3620
  const roots = new Set([node_path__WEBPACK_IMPORTED_MODULE_2__.resolve(sessionRoot)]);
1324
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 */ }
1325
3622
  for (const r of roots) out = out.split(r).join('<root>');
1326
- return (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactString */ .rd)(out);
3623
+ return (0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(out);
1327
3624
  };
1328
3625
  const _gateSummary = (g) => (g ? {
1329
3626
  path: g.path ? g.path.ok : undefined, syntax: g.syntax ? g.syntax.ok : undefined,
@@ -1522,7 +3819,7 @@ const apply_fix = {
1522
3819
  // default. Normalized to the scan+lint verdict shape used below.
1523
3820
  const { verifyFixWithTests } = await __webpack_require__.e(/* import() */ 4113).then(__webpack_require__.bind(__webpack_require__, 64113));
1524
3821
  const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
1525
- verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
3822
+ verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict, verificationRecord: t.verificationRecord ?? null };
1526
3823
  } else {
1527
3824
  const verifyFixCore = await getVerifyFixCore();
1528
3825
  verdict = await verifyFixCore({
@@ -1540,6 +3837,9 @@ const apply_fix = {
1540
3837
  _meta: META, applied: false,
1541
3838
  reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
1542
3839
  verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
3840
+ // X-201: the same version-1 verification record every surface emits (additive; null when none could be formed).
3841
+ verificationRecord: verdict.verificationRecord ?? null,
3842
+ ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(verdict.verificationRecord),
1543
3843
  };
1544
3844
  }
1545
3845
  // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
@@ -1560,7 +3860,7 @@ const apply_fix = {
1560
3860
  }
1561
3861
  const materialClassification = (0,_posture_material_change_js__WEBPACK_IMPORTED_MODULE_6__/* .classifyFixMaterialRisk */ .kz)(filesForMaterialClassification);
1562
3862
  if (dry_run) {
1563
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
3863
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(verdict.verificationRecord), materialClassification };
1564
3864
  }
1565
3865
  const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
1566
3866
  if (approvalRefusal) return approvalRefusal;
@@ -1591,7 +3891,7 @@ const apply_fix = {
1591
3891
  }
1592
3892
  let acceptance = null;
1593
3893
  try { acceptance = (0,_posture_fix_history_js__WEBPACK_IMPORTED_MODULE_4__/* .fixAcceptanceRate */ .XR)(ctx.sessionRoot); } catch { /* best-effort */ }
1594
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
3894
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(verdict.verificationRecord) }, acceptance, materialClassification };
1595
3895
  }
1596
3896
 
1597
3897
  if (typeof f.fix?.replacement !== 'string') {
@@ -1680,7 +3980,7 @@ const apply_fix = {
1680
3980
  // proceed with apply_fix.
1681
3981
  const verify_fix = {
1682
3982
  name: 'verify_fix',
1683
- description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
3983
+ description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary, verificationRecord } (verificationRecord is the shared version-1 record: outcome is one of not-run, unsupported, inconclusive, error, refuted, confirmed, and a static re-scan alone is never reported as confirmed; verificationView is the shared projection every interface shows for it, with state, scope, evidence ids, replay prerequisites and what was and was not verified). Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
1684
3984
  inputSchema: {
1685
3985
  type: 'object',
1686
3986
  additionalProperties: false,
@@ -1768,7 +4068,7 @@ const verify_fix = {
1768
4068
  _meta: META,
1769
4069
  ok: r.ok,
1770
4070
  rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
1771
- lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactString */ .rd)(r.lint.output || '').slice(0, 1500) },
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) },
1772
4072
  // verifyFix computes five legs, not two — tests/honesty/poc were
1773
4073
  // being silently dropped here, leaving an agent with no structured
1774
4074
  // way to see WHY verification failed when the failure was in one
@@ -1779,8 +4079,12 @@ const verify_fix = {
1779
4079
  // it can echo proof-harness detail derived from scanned source.
1780
4080
  tests: r.tests,
1781
4081
  honesty: r.honesty,
1782
- poc: r.poc ? { ...r.poc, reason: r.poc.reason ? (0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactString */ .rd)(r.poc.reason) : r.poc.reason } : r.poc,
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,
1783
4083
  summary: r.summary,
4084
+ // X-201: the one version-1 verification record (additive). It states what was actually established, e.g.
4085
+ // `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
4086
+ verificationRecord: r.verificationRecord ?? null,
4087
+ ...(0,_posture_verification_projection_js__WEBPACK_IMPORTED_MODULE_13__/* .verificationFields */ .ze)(r.verificationRecord),
1784
4088
  };
1785
4089
  } catch (e) {
1786
4090
  return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
@@ -2123,7 +4427,7 @@ const append_agents_memory = {
2123
4427
  required: ['agent', 'body'],
2124
4428
  },
2125
4429
  async handler({ agent, body }, ctx) {
2126
- const r = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_16__/* .appendAgentsMemory */ .eu)(ctx.sessionRoot, { agent, body });
4430
+ const r = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_20__/* .appendAgentsMemory */ .eu)(ctx.sessionRoot, { agent, body });
2127
4431
  return { _meta: META, ...r };
2128
4432
  },
2129
4433
  };
@@ -2139,7 +4443,7 @@ const read_agents_memory = {
2139
4443
  },
2140
4444
  },
2141
4445
  async handler({ full }, ctx) {
2142
- const body = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_16__/* .readAgentsMemory */ .ox)(ctx.sessionRoot);
4446
+ const body = (0,_posture_agents_memory_js__WEBPACK_IMPORTED_MODULE_20__/* .readAgentsMemory */ .ox)(ctx.sessionRoot);
2143
4447
  if (!body) return { _meta: META, present: false };
2144
4448
  if (full) return { _meta: META, present: true, length: body.length, content: body };
2145
4449
  // Tail-only — same logic as summarizeForSession but inlined to avoid a
@@ -2183,7 +4487,7 @@ const query_triage_memory = {
2183
4487
  // they landed in, rather than hardcoding a field allowlist that could
2184
4488
  // miss one.
2185
4489
  let results;
2186
- try { results = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactString */ .rd)(JSON.stringify(raw))); }
4490
+ try { results = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(JSON.stringify(raw))); }
2187
4491
  catch { results = raw; }
2188
4492
  return {
2189
4493
  _meta: META,
@@ -2219,7 +4523,7 @@ const query_findings_memory = {
2219
4523
  // whole-structure redactString round-trip is applied rather than a
2220
4524
  // per-field allowlist that could miss one of the four shapes.
2221
4525
  let body;
2222
- try { body = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_15__/* .redactString */ .rd)(JSON.stringify(raw))); }
4526
+ try { body = JSON.parse((0,_redact_js__WEBPACK_IMPORTED_MODULE_19__/* .redactString */ .rd)(JSON.stringify(raw))); }
2223
4527
  catch { body = raw; }
2224
4528
  return { _meta: META, ...body };
2225
4529
  },
@@ -2241,7 +4545,7 @@ const lookup_cve = {
2241
4545
  required: ['cve'],
2242
4546
  },
2243
4547
  async handler({ cve }, _ctx) {
2244
- const r = (0,_posture_cve_lookup_js__WEBPACK_IMPORTED_MODULE_17__/* .lookupCve */ .x)(cve);
4548
+ const r = (0,_posture_cve_lookup_js__WEBPACK_IMPORTED_MODULE_21__/* .lookupCve */ .x)(cve);
2245
4549
  return { _meta: META, ...r };
2246
4550
  },
2247
4551
  };
@@ -2346,7 +4650,12 @@ const apply_sca_upgrade = {
2346
4650
  },
2347
4651
  };
2348
4652
 
2349
- 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_14__/* .dataflow_get_graph */ .HX, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_14__/* .dataflow_get_node */ .HI, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_14__/* .dataflow_get_edge */ .gC, _dataflow_tools_js__WEBPACK_IMPORTED_MODULE_14__/* .dataflow_get_flow */ .ri];
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];
2350
4659
 
2351
4660
  __webpack_async_result__();
2352
4661
  } catch(e) { __webpack_async_result__(e); } });