thumbgate 1.35.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +336 -3
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +581 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +9 -3
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +6 -0
  34. package/config/model-candidates.json +312 -29
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +390 -148
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/billing.js +1 -1
  89. package/scripts/broker-execution-receipts.js +719 -0
  90. package/scripts/budget-aware-gates-proof.js +423 -0
  91. package/scripts/claude-feedback-sync.js +29 -3
  92. package/scripts/claw-harness-production.js +237 -0
  93. package/scripts/cli-schema.js +163 -1
  94. package/scripts/codex-runbook-flywheel.js +318 -0
  95. package/scripts/context-footprint.js +186 -0
  96. package/scripts/contextfs.js +143 -61
  97. package/scripts/dashboard.js +251 -32
  98. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  99. package/scripts/docker-sandbox-planner.js +18 -0
  100. package/scripts/double-blind-eval-protocol.js +252 -0
  101. package/scripts/edotenv-rl-gateway.js +259 -0
  102. package/scripts/ensure-production-search-corpus.js +162 -0
  103. package/scripts/eval-holdout.js +311 -0
  104. package/scripts/feedback-aggregate.js +21 -2
  105. package/scripts/feedback-loop.js +87 -5
  106. package/scripts/feedback-quality.js +9 -0
  107. package/scripts/file-ledger-lock.js +4 -1
  108. package/scripts/financial-control-plane.js +41 -1
  109. package/scripts/find-dormant-requires.js +118 -0
  110. package/scripts/fs-utils.js +84 -8
  111. package/scripts/gates-engine.js +810 -63
  112. package/scripts/generate-case-study-outreach.js +24 -15
  113. package/scripts/git-at-scale.js +628 -0
  114. package/scripts/governance-conflict-audit.js +1650 -0
  115. package/scripts/governance-difficulty-curriculum.js +328 -0
  116. package/scripts/graphrag-retrieval.js +275 -0
  117. package/scripts/gurobi-optimizer.js +324 -0
  118. package/scripts/gurobi_optimizer.py +485 -0
  119. package/scripts/harness-selector.js +82 -1
  120. package/scripts/hidden-entry-points.js +284 -0
  121. package/scripts/human-escalation.js +199 -1
  122. package/scripts/hybrid-feedback-context.js +152 -19
  123. package/scripts/intent-governed-execution.js +602 -0
  124. package/scripts/intervention-policy.js +123 -20
  125. package/scripts/jit-harness-compose.js +628 -0
  126. package/scripts/jsonl-watcher.js +10 -0
  127. package/scripts/lesson-embedding-index.js +95 -12
  128. package/scripts/lesson-retrieval.js +105 -19
  129. package/scripts/local-model-profile.js +19 -2
  130. package/scripts/mailer/resend-mailer.js +1 -1
  131. package/scripts/matryoshka-embedding.js +235 -0
  132. package/scripts/mcp-oauth.js +42 -4
  133. package/scripts/mcp-session-handles.js +1016 -0
  134. package/scripts/mcp-wiring-doctor.js +314 -0
  135. package/scripts/memory-firewall.js +115 -2
  136. package/scripts/memory-scope-readiness.js +299 -0
  137. package/scripts/memory-vs-rag-route.js +161 -0
  138. package/scripts/model-tier-router.js +148 -21
  139. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  140. package/scripts/openui-catalog-compose-honesty.js +593 -0
  141. package/scripts/operational-integrity.js +19 -1
  142. package/scripts/override-audit.js +213 -0
  143. package/scripts/package-manager-honesty-doctor.js +458 -0
  144. package/scripts/pr-manager.js +63 -1
  145. package/scripts/prove-herdr-adapter.js +52 -0
  146. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  147. package/scripts/prove-workos.js +73 -0
  148. package/scripts/provider-attestation-conformance.js +192 -0
  149. package/scripts/provider-receipt-contract.js +136 -0
  150. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  151. package/scripts/radware-threat-defense.js +280 -0
  152. package/scripts/rag-embedding-identity.js +221 -0
  153. package/scripts/rag-precision-guardrails.js +112 -2
  154. package/scripts/remote-feedback-capture.js +159 -0
  155. package/scripts/research-agent-harness.js +256 -0
  156. package/scripts/rsi-safety-hillclimb.js +200 -0
  157. package/scripts/rule-sprawl.js +188 -0
  158. package/scripts/schedule-manager.js +147 -0
  159. package/scripts/self-heal.js +8 -0
  160. package/scripts/session-lease.js +415 -0
  161. package/scripts/simatree-data-governance.js +347 -0
  162. package/scripts/slo-alert-engine.js +172 -7
  163. package/scripts/solver-parity.js +539 -0
  164. package/scripts/stealth-memory-injection-gate.js +333 -0
  165. package/scripts/switchyard-router.js +366 -0
  166. package/scripts/telemetry-analytics.js +84 -27
  167. package/scripts/temporal-decay-weighting.js +138 -0
  168. package/scripts/test-all.js +165 -0
  169. package/scripts/token-savings.js +42 -0
  170. package/scripts/tool-kpi-tracker.js +108 -5
  171. package/scripts/tool-registry.js +193 -5
  172. package/scripts/universal-claim-evaluator.js +14 -2
  173. package/scripts/vector-store.js +279 -9
  174. package/scripts/workflow-notebook.js +391 -0
  175. package/scripts/workflow-sentinel.js +111 -12
  176. package/scripts/workos-production-guard.js +260 -0
  177. package/scripts/workspace-search-route.js +515 -0
  178. package/server.json +2 -2
  179. package/src/agent-identity-boundary.js +76 -0
  180. package/src/agent-retrieval-cache.js +155 -0
  181. package/src/alert-noise-ledger.js +502 -0
  182. package/src/api/server.js +724 -153
  183. package/src/git-fast-cache.js +220 -0
  184. package/src/git-wal-sync.js +156 -0
  185. package/src/hash-anchored-edit.js +82 -0
  186. package/src/hermes-platform-protocol.js +475 -0
  187. package/src/hermes-sync-plane.js +241 -0
  188. package/src/index.js +30 -1
  189. package/src/iso42001-compliance-guard.js +97 -0
  190. package/src/latency-budget.js +244 -0
  191. package/src/mcp-writeguard.js +316 -0
  192. package/src/miminions-adapter.js +106 -0
  193. package/src/pipeline-compass.js +104 -0
  194. package/src/ppl-alert-pipeline.js +284 -0
  195. package/src/rendezvous-router.js +90 -0
  196. package/src/security-questionnaire.js +195 -0
@@ -0,0 +1,1117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Egress Policy — CrabTrap-inspired controls at the tool layer
5
+ *
6
+ * Competitor (Brex CrabTrap, open-source 2026): HTTP/HTTPS MITM proxy that
7
+ * evaluates every outbound request with (1) static URL rules first and
8
+ * (2) LLM-as-a-judge on the long tail; policies drafted from observed traffic;
9
+ * replay eval before go-live; SSRF private-range blocks.
10
+ *
11
+ * Competitive assessment:
12
+ * - NOT a 1:1 product competitor. CrabTrap owns transport (HTTP_PROXY/MITM).
13
+ * - ThumbGate owns PreToolUse / MCP / Bash gates + feedback→prevention.
14
+ * - Overlap: allow/deny egress policy, audit attribution, observe→promote.
15
+ * - Complementary: run CrabTrap for all sockets AND ThumbGate for tool actions.
16
+ *
17
+ * High-ROI transfers implemented here (no TLS MITM required):
18
+ * 1. Two-tier evaluate: static rules (deny wins) → optional long-tail judge
19
+ * 2. Observe-mode ledger + draftPolicyFromObservations
20
+ * 3. replayPolicy against audit before promoting a draft
21
+ * 4. SSRF / private-network fail-closed
22
+ * 5. Injection-safe structured request view for any LLM judge (JSON, caps)
23
+ * 6. Decision attribution: STATIC_DENY | STATIC_ALLOW | STATIC_ALLOW_BRIDGE | SSRF | OBSERVE | JUDGE | FALLBACK
24
+ * 7. GitLab 2026-09 FORMAT: allowlisted package registries/proxies/HF are hops, not trust
25
+ * boundaries (ALLOWLIST_BRIDGE_CREDENTIAL deny when secrets ride the hop)
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+ const { URL } = require('url');
32
+
33
+ const DEFAULT_HEADER_CAP = 4 * 1024;
34
+ const DEFAULT_BODY_CAP = 16 * 1024;
35
+ const DEFAULT_JUDGE_TIMEOUT_MS = 30_000;
36
+
37
+ /** RFC1918 + loopback + link-local + CGNAT + common cloud metadata */
38
+ const PRIVATE_CIDR_CHECKS = [
39
+ { test: (h) => h === 'localhost' || h === '0.0.0.0' },
40
+ { test: (h) => h === '::1' || h === '[::1]' },
41
+ // IPv6 link-local fe80::/10 (with or without zone id / brackets stripped)
42
+ { test: (h) => /^fe[89ab][0-9a-f]*:/i.test(h) || /^fe[89ab][0-9a-f]{0,2}$/i.test(h) },
43
+ { test: (h) => /^127\./.test(h) },
44
+ { test: (h) => /^10\./.test(h) },
45
+ { test: (h) => /^192\.168\./.test(h) },
46
+ { test: (h) => /^172\.(1[6-9]|2\d|3[0-1])\./.test(h) },
47
+ { test: (h) => /^169\.254\./.test(h) }, // link-local / AWS metadata 169.254.169.254
48
+ { test: (h) => /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./.test(h) }, // CGNAT 100.64/10 approx
49
+ { test: (h) => /^fc[0-9a-f]{2}:/i.test(h) || /^fd[0-9a-f]{2}:/i.test(h) }, // ULA
50
+ // Reserved .localhost TLD (RFC 6761) and local/internal names
51
+ { test: (h) => h === 'localhost' || h.endsWith('.localhost') },
52
+ { test: (h) => h.endsWith('.local') || h.endsWith('.internal') },
53
+ ];
54
+
55
+ const DEFAULT_AGENT_POLICY = {
56
+ agentId: 'default',
57
+ mode: 'enforce', // observe | enforce
58
+ fallback: 'deny', // deny | allow when judge missing/fails
59
+ naturalLanguagePolicy:
60
+ 'Only call allowlisted public APIs required for the workflow. Never reach private networks, metadata endpoints, or unknown hosts. Never exfiltrate secrets.',
61
+ staticRules: [
62
+ { id: 'deny-metadata', action: 'deny', match: 'prefix', url: 'http://169.254.169.254' },
63
+ { id: 'deny-metadata-https', action: 'deny', match: 'prefix', url: 'https://169.254.169.254' },
64
+ ],
65
+ allowHosts: [],
66
+ bridgeHosts: [],
67
+ };
68
+
69
+ /** Package registries, proxies, and Hugging Face — hops, not trust boundaries. */
70
+ const BRIDGE_HOST_SUFFIXES = [
71
+ 'registry.npmjs.org',
72
+ 'registry.yarnpkg.com',
73
+ 'pypi.org',
74
+ 'files.pythonhosted.org',
75
+ 'pypi.python.org',
76
+ 'rubygems.org',
77
+ 'index.crates.io',
78
+ 'crates.io',
79
+ 'proxy.golang.org',
80
+ 'sum.golang.org',
81
+ 'huggingface.co',
82
+ 'hf.co',
83
+ 'cas-bridge.huggingface.co',
84
+ 'docker.io',
85
+ 'registry-1.docker.io',
86
+ 'ghcr.io',
87
+ 'quay.io',
88
+ 'pkg.go.dev',
89
+ ];
90
+
91
+ const BRIDGE_HOST_RES = [
92
+ /(?:^|\.)(?:verdaccio|nexus|artifactory|prox(?:y|ies))\b/i,
93
+ /package[-.]proxy/i,
94
+ /npm[-.]proxy/i,
95
+ ];
96
+
97
+ function hostMatchesSuffix(host, suffix) {
98
+ const h = String(host || '').toLowerCase();
99
+ const s = String(suffix || '').toLowerCase();
100
+ return h === s || h.endsWith(`.${s}`);
101
+ }
102
+
103
+ function classifyHostRole(host) {
104
+ const h = String(host || '')
105
+ .trim()
106
+ .toLowerCase()
107
+ .replace(/^https?:\/\//, '')
108
+ .split('/')[0]
109
+ .split(':')[0];
110
+ if (!h) {
111
+ return {
112
+ host: h,
113
+ role: 'unknown',
114
+ trusted: false,
115
+ independentAuthRequired: true,
116
+ };
117
+ }
118
+ if (isPrivateOrLinkLocalHost(h)) {
119
+ return {
120
+ host: h,
121
+ role: 'ssrf_private',
122
+ trusted: false,
123
+ independentAuthRequired: true,
124
+ reason: 'Private, link-local, or cloud-metadata host is never a sandbox trust boundary.',
125
+ };
126
+ }
127
+ const isBridge = BRIDGE_HOST_SUFFIXES.some((s) => hostMatchesSuffix(h, s))
128
+ || BRIDGE_HOST_RES.some((re) => re.test(h));
129
+ if (isBridge) {
130
+ return {
131
+ host: h,
132
+ role: 'bridge',
133
+ trusted: false,
134
+ independentAuthRequired: true,
135
+ reason: 'Allowlisted package registry/proxy/HF is a hop, not a trust boundary (GitLab 2026-09).',
136
+ };
137
+ }
138
+ return {
139
+ host: h,
140
+ role: 'public',
141
+ trusted: false,
142
+ independentAuthRequired: false,
143
+ };
144
+ }
145
+
146
+ function requestCarriesSecrets(request = {}) {
147
+ const headers = request.headers && typeof request.headers === 'object' ? request.headers : {};
148
+ for (const key of Object.keys(headers)) {
149
+ const lower = String(key).toLowerCase();
150
+ if (
151
+ lower === 'authorization'
152
+ || lower === 'proxy-authorization'
153
+ || lower === 'cookie'
154
+ || lower.includes('api-key')
155
+ || lower.includes('huggingface')
156
+ || (lower.includes('token') && lower !== 'content-type')
157
+ ) {
158
+ const value = String(headers[key] || '').trim();
159
+ if (value && value !== '[REDACTED]') return true;
160
+ }
161
+ }
162
+ const url = String(request.url || request.target || '');
163
+ if (/https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(url)) return true;
164
+ const body = request.body == null ? '' : String(request.body);
165
+ if (/\b(authorization|x-api-key|hf_tok)\b/i.test(body) && /bearer|hf_|sk-|ghp_/i.test(body)) {
166
+ return true;
167
+ }
168
+ return Boolean(request.carriesSecrets);
169
+ }
170
+
171
+ function nowIso() {
172
+ return new Date().toISOString();
173
+ }
174
+
175
+ function normalizeMethod(method) {
176
+ return String(method || 'GET').trim().toUpperCase() || 'GET';
177
+ }
178
+
179
+ function parseTarget(input = {}) {
180
+ const rawUrl = input.url || input.target || input.href || '';
181
+ const method = normalizeMethod(input.method);
182
+ let url = String(rawUrl || '').trim();
183
+ let host = String(input.host || '').trim().toLowerCase();
184
+ let pathname = '/';
185
+
186
+ if (!url && host) {
187
+ url = `https://${host}${input.path || '/'}`;
188
+ }
189
+
190
+ if (url && !/^https?:\/\//i.test(url) && !url.includes('://')) {
191
+ // Bare host or host/path
192
+ if (url.includes('/') || url.includes('.')) {
193
+ url = `https://${url}`;
194
+ }
195
+ }
196
+
197
+ try {
198
+ if (url) {
199
+ const u = new URL(url);
200
+ host = (u.hostname || host).toLowerCase();
201
+ pathname = u.pathname || '/';
202
+ url = u.toString();
203
+ }
204
+ } catch {
205
+ // keep raw
206
+ if (!host && url) {
207
+ host = url.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
208
+ }
209
+ }
210
+
211
+ return {
212
+ method,
213
+ url,
214
+ host,
215
+ pathname,
216
+ headers: input.headers && typeof input.headers === 'object' ? input.headers : {},
217
+ body: input.body != null ? input.body : null,
218
+ toolName: input.toolName || input.tool || null,
219
+ agentId: input.agentId || null,
220
+ principalId: input.principalId || null,
221
+ metadata: input.metadata && typeof input.metadata === 'object' ? input.metadata : {},
222
+ };
223
+ }
224
+
225
+ function isPrivateOrLinkLocalHost(host) {
226
+ const h = String(host || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
227
+ if (!h) return false;
228
+ if (h === 'metadata.google.internal') return true;
229
+ return PRIVATE_CIDR_CHECKS.some((c) => c.test(h));
230
+ }
231
+
232
+ function compileRule(rule) {
233
+ const match = String(rule.match || rule.type || 'prefix').toLowerCase();
234
+ const pattern = String(rule.url || rule.pattern || rule.host || '');
235
+ const methods = Array.isArray(rule.methods)
236
+ ? rule.methods.map((m) => String(m).toUpperCase())
237
+ : rule.method
238
+ ? [String(rule.method).toUpperCase()]
239
+ : null;
240
+ const action = String(rule.action || 'allow').toLowerCase() === 'deny' ? 'deny' : 'allow';
241
+ let re = null;
242
+ if (match === 'exact') {
243
+ re = null;
244
+ } else if (match === 'glob') {
245
+ const escaped = pattern
246
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
247
+ .replace(/\*/g, '.*')
248
+ .replace(/\?/g, '.');
249
+ re = new RegExp(`^${escaped}$`, 'i');
250
+ } else if (match === 'regex') {
251
+ re = new RegExp(pattern, 'i');
252
+ } else if (match === 'host') {
253
+ re = null;
254
+ } else {
255
+ // prefix default
256
+ re = null;
257
+ }
258
+ return { ...rule, match, pattern, methods, action, re, id: rule.id || `rule_${action}_${match}` };
259
+ }
260
+
261
+ /**
262
+ * Origin-boundary prefix match: scheme+host must equal the rule origin exactly
263
+ * (or be an exact host when the pattern is host-only). Pathname may then extend
264
+ * the prefix. Prevents `https://api.example.com.evil.test` matching
265
+ * `https://api.example.com`.
266
+ */
267
+ function prefixMatchesAtOriginBoundary(target, pattern) {
268
+ const raw = String(pattern || '').trim();
269
+ if (!raw) return false;
270
+ const hay = target.url || '';
271
+ const host = String(target.host || '').toLowerCase();
272
+ const pathname = target.pathname || '/';
273
+
274
+ // Host-only pattern (no scheme): require exact host equality, then optional path prefix.
275
+ if (!/^https?:\/\//i.test(raw)) {
276
+ const hostPart = raw.split('/')[0].toLowerCase();
277
+ const pathPart = raw.includes('/') ? raw.slice(raw.indexOf('/')) : '';
278
+ if (host !== hostPart) return false;
279
+ if (!pathPart) return true;
280
+ return pathname === pathPart || pathname.startsWith(pathPart.endsWith('/') ? pathPart : `${pathPart}`);
281
+ }
282
+
283
+ let ruleUrl;
284
+ try {
285
+ ruleUrl = new URL(raw);
286
+ } catch {
287
+ return hay.startsWith(raw); // non-URL literal: strict string prefix only on full URL
288
+ }
289
+
290
+ let targetUrl;
291
+ try {
292
+ targetUrl = hay ? new URL(hay) : new URL(`https://${host}${pathname}`);
293
+ } catch {
294
+ return false;
295
+ }
296
+
297
+ if (targetUrl.protocol.toLowerCase() !== ruleUrl.protocol.toLowerCase()) return false;
298
+ if (targetUrl.hostname.toLowerCase() !== ruleUrl.hostname.toLowerCase()) return false;
299
+ // Port: if rule specifies a port, require match; else ignore target port variance for defaults.
300
+ if (ruleUrl.port && ruleUrl.port !== targetUrl.port) return false;
301
+
302
+ const rulePath = ruleUrl.pathname || '/';
303
+ const targetPath = targetUrl.pathname || '/';
304
+ // Path prefix at a segment-friendly boundary: exact path or path + more.
305
+ if (rulePath === '/') {
306
+ // Origin-only rule (https://api.example.com or https://api.example.com/)
307
+ return true;
308
+ }
309
+ return targetPath === rulePath
310
+ || targetPath.startsWith(rulePath.endsWith('/') ? rulePath : `${rulePath}/`)
311
+ || targetPath.startsWith(rulePath);
312
+ }
313
+
314
+ function ruleMatches(compiled, target) {
315
+ if (compiled.methods && !compiled.methods.includes(target.method)) return false;
316
+ const hay = target.url || `https://${target.host}${target.pathname || '/'}`;
317
+ const host = target.host || '';
318
+
319
+ switch (compiled.match) {
320
+ case 'exact':
321
+ return hay === compiled.pattern || host === compiled.pattern.toLowerCase();
322
+ case 'host':
323
+ return host === compiled.pattern.toLowerCase()
324
+ || host.endsWith(`.${compiled.pattern.toLowerCase()}`);
325
+ case 'glob':
326
+ case 'regex':
327
+ return compiled.re ? compiled.re.test(hay) || compiled.re.test(host) : false;
328
+ case 'prefix':
329
+ default:
330
+ return prefixMatchesAtOriginBoundary(target, compiled.pattern);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Static-rule pass. Deny always wins over allow (CrabTrap invariant).
336
+ * @returns {{ hit: boolean, action?: string, rule?: object }}
337
+ */
338
+ function matchStaticRules(target, rules = []) {
339
+ const compiled = (rules || []).map(compileRule);
340
+ const denies = compiled.filter((r) => r.action === 'deny');
341
+ const allows = compiled.filter((r) => r.action === 'allow');
342
+
343
+ for (const rule of denies) {
344
+ if (ruleMatches(rule, target)) {
345
+ return { hit: true, action: 'deny', rule, judgmentType: 'STATIC_DENY' };
346
+ }
347
+ }
348
+ for (const rule of allows) {
349
+ if (ruleMatches(rule, target)) {
350
+ return { hit: true, action: 'allow', rule, judgmentType: 'STATIC_ALLOW' };
351
+ }
352
+ }
353
+ return { hit: false };
354
+ }
355
+
356
+ /**
357
+ * Build an injection-safe structured view of the request for an LLM judge.
358
+ * User-controlled content is nested as JSON (not raw prompt interpolation).
359
+ * Headers capped at 4KB; body at 16KB with explicit truncation markers.
360
+ */
361
+ function buildJudgeSafeRequestView(request = {}, options = {}) {
362
+ const target = parseTarget(request);
363
+ const headerCap = options.headerCap || DEFAULT_HEADER_CAP;
364
+ const bodyCap = options.bodyCap || DEFAULT_BODY_CAP;
365
+
366
+ const headers = {};
367
+ let headerBytes = 0;
368
+ const entries = Object.entries(target.headers || {});
369
+ // Prefer security-relevant headers first (CrabTrap pattern)
370
+ const priority = ['authorization', 'cookie', 'x-api-key', 'x-auth-token', 'host', 'content-type', 'user-agent'];
371
+ entries.sort((a, b) => {
372
+ const ai = priority.indexOf(String(a[0]).toLowerCase());
373
+ const bi = priority.indexOf(String(b[0]).toLowerCase());
374
+ if (ai === -1 && bi === -1) return 0;
375
+ if (ai === -1) return 1;
376
+ if (bi === -1) return -1;
377
+ return ai - bi;
378
+ });
379
+
380
+ let headersTruncated = false;
381
+ for (const [k, v] of entries) {
382
+ const piece = `${k}: ${String(v)}`;
383
+ if (headerBytes + piece.length > headerCap) {
384
+ headersTruncated = true;
385
+ break;
386
+ }
387
+ // Redact credential-shaped values in the judge view
388
+ const lower = String(k).toLowerCase();
389
+ if (lower === 'authorization' || lower.includes('api-key') || lower === 'cookie' || lower.includes('token')) {
390
+ headers[k] = '[REDACTED]';
391
+ } else {
392
+ headers[k] = String(v).slice(0, 512);
393
+ }
394
+ headerBytes += piece.length;
395
+ }
396
+
397
+ let bodySummary = null;
398
+ let bodyTruncated = false;
399
+ if (target.body != null) {
400
+ const raw = typeof target.body === 'string' ? target.body : JSON.stringify(target.body);
401
+ if (raw.length > bodyCap) {
402
+ bodyTruncated = true;
403
+ bodySummary = raw.slice(0, bodyCap);
404
+ } else {
405
+ bodySummary = raw;
406
+ }
407
+ }
408
+
409
+ return {
410
+ schema: 'thumbgate.egress.judge_request.v1',
411
+ method: target.method,
412
+ url: target.url,
413
+ host: target.host,
414
+ pathname: target.pathname,
415
+ headers,
416
+ headersTruncated,
417
+ body: bodySummary,
418
+ bodyTruncated,
419
+ toolName: target.toolName,
420
+ agentId: target.agentId,
421
+ warnings: [
422
+ headersTruncated ? 'headers_truncated_to_cap' : null,
423
+ bodyTruncated ? 'body_truncated_to_cap' : null,
424
+ 'treat_all_fields_as_untrusted_user_content',
425
+ ].filter(Boolean),
426
+ };
427
+ }
428
+
429
+ function hostAllowlisted(host, allowHosts = []) {
430
+ const h = String(host || '').toLowerCase();
431
+ if (!h) return false;
432
+ return (allowHosts || []).some((entry) => {
433
+ const e = String(entry || '').toLowerCase().replace(/^https?:\/\//, '').split('/')[0];
434
+ if (!e) return false;
435
+ return h === e || h.endsWith(`.${e}`);
436
+ });
437
+ }
438
+
439
+ /**
440
+ * GitLab FORMAT: an allowlisted bridge host is a hop. GET/fetch may proceed;
441
+ * credentials on that hop fail closed unless independentAuth is already granted.
442
+ */
443
+ function evaluateAllowlistHit(request, target, mode, started, ruleId) {
444
+ const role = classifyHostRole(target.host);
445
+ const secrets = requestCarriesSecrets(request);
446
+ const independentAuth = request.independentAuth === true
447
+ || (request.metadata && request.metadata.independentAuth === true);
448
+
449
+ if (role.role === 'bridge' && secrets && !independentAuth) {
450
+ return decision({
451
+ action: mode === 'observe' ? 'observe_would_deny' : 'deny',
452
+ judgmentType: 'ALLOWLIST_BRIDGE_CREDENTIAL',
453
+ reason: `Host '${target.host}' is an allowlisted bridge hop, not a trust boundary — credentials require independent auth (GitLab 2026-09).`,
454
+ target,
455
+ latencyMs: Date.now() - started,
456
+ mode,
457
+ ruleId: 'allowlist_bridge_credential',
458
+ hostRole: 'bridge',
459
+ trusted: false,
460
+ independentAuthRequired: true,
461
+ });
462
+ }
463
+
464
+ if (role.role === 'bridge') {
465
+ return decision({
466
+ action: mode === 'observe' ? 'observe_would_allow' : 'allow',
467
+ judgmentType: 'STATIC_ALLOW_BRIDGE',
468
+ reason: `Host '${target.host}' is allowlisted as a bridge hop, not a trust boundary`,
469
+ target,
470
+ latencyMs: Date.now() - started,
471
+ mode,
472
+ ruleId: ruleId || 'allowlist_bridge_host',
473
+ hostRole: 'bridge',
474
+ trusted: false,
475
+ independentAuthRequired: true,
476
+ });
477
+ }
478
+
479
+ return decision({
480
+ action: mode === 'observe' ? 'observe_would_allow' : 'allow',
481
+ judgmentType: 'STATIC_ALLOW',
482
+ reason: `Host '${target.host}' is on the agent allowlist`,
483
+ target,
484
+ latencyMs: Date.now() - started,
485
+ mode,
486
+ ruleId: ruleId || 'allowlist_host',
487
+ hostRole: role.role,
488
+ trusted: false,
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Evaluate an egress request against a policy.
494
+ * @param {object} request - { method, url|host, headers, body, toolName, agentId }
495
+ * @param {object} policy
496
+ * @param {object} options - { judge?: async (view, policy) => {allow, reason}, mode? }
497
+ */
498
+ async function evaluateEgress(request = {}, policy = {}, options = {}) {
499
+ const started = Date.now();
500
+ const target = parseTarget(request);
501
+ const pol = {
502
+ ...DEFAULT_AGENT_POLICY,
503
+ ...policy,
504
+ staticRules: [
505
+ ...(DEFAULT_AGENT_POLICY.staticRules || []),
506
+ ...(policy.staticRules || []),
507
+ ],
508
+ allowHosts: policy.allowHosts || policy.allowedHosts || DEFAULT_AGENT_POLICY.allowHosts,
509
+ bridgeHosts: policy.bridgeHosts || DEFAULT_AGENT_POLICY.bridgeHosts,
510
+ };
511
+ const mode = options.mode || pol.mode || 'enforce';
512
+
513
+ // Tier 0: SSRF / private network (always, even in observe)
514
+ if (target.host && isPrivateOrLinkLocalHost(target.host)) {
515
+ return decision({
516
+ action: mode === 'observe' ? 'observe_would_deny' : 'deny',
517
+ judgmentType: 'SSRF_PRIVATE_NETWORK',
518
+ reason: `Host '${target.host}' is private, link-local, or metadata — blocked (SSRF).`,
519
+ target,
520
+ latencyMs: Date.now() - started,
521
+ mode,
522
+ ruleId: 'ssrf_private_network',
523
+ });
524
+ }
525
+
526
+ // Tier 1: static rules (deny wins)
527
+ const staticHit = matchStaticRules(target, pol.staticRules);
528
+ if (staticHit.hit) {
529
+ const denied = staticHit.action === 'deny';
530
+ if (!denied && classifyHostRole(target.host).role === 'bridge') {
531
+ return evaluateAllowlistHit(request, target, mode, started, staticHit.rule.id);
532
+ }
533
+ return decision({
534
+ action: mode === 'observe'
535
+ ? (denied ? 'observe_would_deny' : 'observe_would_allow')
536
+ : staticHit.action,
537
+ judgmentType: staticHit.judgmentType,
538
+ reason: denied
539
+ ? `Denied by static rule '${staticHit.rule.id}'`
540
+ : `Allowed by static rule '${staticHit.rule.id}'`,
541
+ target,
542
+ latencyMs: Date.now() - started,
543
+ mode,
544
+ ruleId: staticHit.rule.id,
545
+ });
546
+ }
547
+
548
+ // Implicit host allowlist = static allow (bridge hops still untrusted)
549
+ if (hostAllowlisted(target.host, pol.allowHosts) || hostAllowlisted(target.host, pol.bridgeHosts)) {
550
+ return evaluateAllowlistHit(request, target, mode, started, 'allowlist_host');
551
+ }
552
+
553
+ // Tier 2: long-tail LLM judge (optional)
554
+ const judge = options.judge || pol.judge || null;
555
+ if (typeof judge === 'function') {
556
+ const view = buildJudgeSafeRequestView(target, options);
557
+ let timeoutHandle = null;
558
+ try {
559
+ const timeoutMs = options.judgeTimeoutMs || DEFAULT_JUDGE_TIMEOUT_MS;
560
+ const verdict = await Promise.race([
561
+ Promise.resolve(judge(view, pol)),
562
+ new Promise((_, reject) => {
563
+ timeoutHandle = setTimeout(() => reject(new Error('judge_timeout')), timeoutMs);
564
+ // Do not keep the event loop alive solely for the timeout when judge wins.
565
+ if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref();
566
+ }),
567
+ ]);
568
+ if (timeoutHandle) clearTimeout(timeoutHandle);
569
+ const allow = verdict && (verdict.allow === true || verdict.action === 'allow' || verdict.decision === 'allow');
570
+ return decision({
571
+ action: mode === 'observe'
572
+ ? (allow ? 'observe_would_allow' : 'observe_would_deny')
573
+ : (allow ? 'allow' : 'deny'),
574
+ judgmentType: 'LLM_JUDGE',
575
+ reason: (verdict && (verdict.reason || verdict.message)) || (allow ? 'Judge allowed' : 'Judge denied'),
576
+ target,
577
+ latencyMs: Date.now() - started,
578
+ mode,
579
+ ruleId: 'llm_judge',
580
+ judgeView: options.includeJudgeView ? view : undefined,
581
+ });
582
+ } catch (err) {
583
+ if (timeoutHandle) clearTimeout(timeoutHandle);
584
+ const fallback = String(options.fallback || pol.fallback || 'deny').toLowerCase();
585
+ const allow = fallback === 'allow' || fallback === 'passthrough';
586
+ return decision({
587
+ action: mode === 'observe'
588
+ ? (allow ? 'observe_would_allow' : 'observe_would_deny')
589
+ : (allow ? 'allow' : 'deny'),
590
+ judgmentType: 'JUDGE_FALLBACK',
591
+ reason: `Judge unavailable (${err.message || err}); fallback=${fallback}`,
592
+ target,
593
+ latencyMs: Date.now() - started,
594
+ mode,
595
+ ruleId: 'judge_fallback',
596
+ });
597
+ }
598
+ }
599
+
600
+ // No static match, no judge: enforce defaults to deny unknown (CrabTrap production posture)
601
+ if (mode === 'observe') {
602
+ return decision({
603
+ action: 'observe',
604
+ judgmentType: 'OBSERVE',
605
+ reason: 'Observe mode: no static rule matched; recorded for policy drafting',
606
+ target,
607
+ latencyMs: Date.now() - started,
608
+ mode,
609
+ ruleId: 'observe_unmatched',
610
+ });
611
+ }
612
+
613
+ return decision({
614
+ action: 'deny',
615
+ judgmentType: 'DEFAULT_DENY_UNKNOWN',
616
+ reason: `No static rule or allowlist match for host '${target.host || target.url}'. Denied unknown egress.`,
617
+ target,
618
+ latencyMs: Date.now() - started,
619
+ mode,
620
+ ruleId: 'default_deny_unknown',
621
+ });
622
+ }
623
+
624
+ function decision(partial) {
625
+ const action = partial.action;
626
+ const allowed = action === 'allow' || action === 'observe_would_allow' || action === 'observe';
627
+ return {
628
+ allowed: action === 'allow' || action === 'observe' || action === 'observe_would_allow',
629
+ action,
630
+ status: action === 'allow' || action === 'observe' || action === 'observe_would_allow' ? 200 : 403,
631
+ judgmentType: partial.judgmentType,
632
+ reason: partial.reason,
633
+ ruleId: partial.ruleId || null,
634
+ mode: partial.mode,
635
+ latencyMs: partial.latencyMs,
636
+ target: {
637
+ method: partial.target.method,
638
+ url: partial.target.url,
639
+ host: partial.target.host,
640
+ toolName: partial.target.toolName,
641
+ },
642
+ interdictionSource: 'ThumbGate-Egress-Policy',
643
+ at: nowIso(),
644
+ ...(partial.judgeView ? { judgeView: partial.judgeView } : {}),
645
+ ...(partial.hostRole ? { hostRole: partial.hostRole } : {}),
646
+ ...(partial.trusted !== undefined ? { trusted: partial.trusted } : {}),
647
+ ...(partial.independentAuthRequired !== undefined
648
+ ? { independentAuthRequired: partial.independentAuthRequired }
649
+ : {}),
650
+ };
651
+ }
652
+
653
+ /** Sync wrapper for PreToolUse hooks that cannot await (static + SSRF only). */
654
+ function evaluateEgressSync(request = {}, policy = {}, options = {}) {
655
+ return evaluateEgressStaticOnly(request, policy, options);
656
+ }
657
+
658
+ function evaluateEgressStaticOnly(request = {}, policy = {}, options = {}) {
659
+ const started = Date.now();
660
+ const target = parseTarget(request);
661
+ const pol = {
662
+ ...DEFAULT_AGENT_POLICY,
663
+ ...policy,
664
+ staticRules: [
665
+ ...(DEFAULT_AGENT_POLICY.staticRules || []),
666
+ ...(policy.staticRules || []),
667
+ ],
668
+ allowHosts: policy.allowHosts || policy.allowedHosts || [],
669
+ bridgeHosts: policy.bridgeHosts || [],
670
+ };
671
+ const mode = options.mode || pol.mode || 'enforce';
672
+
673
+ if (target.host && isPrivateOrLinkLocalHost(target.host)) {
674
+ return decision({
675
+ action: mode === 'observe' ? 'observe_would_deny' : 'deny',
676
+ judgmentType: 'SSRF_PRIVATE_NETWORK',
677
+ reason: `Host '${target.host}' is private, link-local, or metadata — blocked (SSRF).`,
678
+ target,
679
+ latencyMs: Date.now() - started,
680
+ mode,
681
+ ruleId: 'ssrf_private_network',
682
+ });
683
+ }
684
+
685
+ const staticHit = matchStaticRules(target, pol.staticRules);
686
+ if (staticHit.hit) {
687
+ const denied = staticHit.action === 'deny';
688
+ if (!denied && classifyHostRole(target.host).role === 'bridge') {
689
+ return evaluateAllowlistHit(request, target, mode, started, staticHit.rule.id);
690
+ }
691
+ return decision({
692
+ action: mode === 'observe'
693
+ ? (denied ? 'observe_would_deny' : 'observe_would_allow')
694
+ : staticHit.action,
695
+ judgmentType: staticHit.judgmentType,
696
+ reason: denied
697
+ ? `Denied by static rule '${staticHit.rule.id}'`
698
+ : `Allowed by static rule '${staticHit.rule.id}'`,
699
+ target,
700
+ latencyMs: Date.now() - started,
701
+ mode,
702
+ ruleId: staticHit.rule.id,
703
+ });
704
+ }
705
+
706
+ if (hostAllowlisted(target.host, pol.allowHosts) || hostAllowlisted(target.host, pol.bridgeHosts)) {
707
+ return evaluateAllowlistHit(request, target, mode, started, 'allowlist_host');
708
+ }
709
+
710
+ if (mode === 'observe') {
711
+ return decision({
712
+ action: 'observe',
713
+ judgmentType: 'OBSERVE',
714
+ reason: 'Observe mode: unmatched egress recorded',
715
+ target,
716
+ latencyMs: Date.now() - started,
717
+ mode,
718
+ ruleId: 'observe_unmatched',
719
+ });
720
+ }
721
+
722
+ return decision({
723
+ action: 'deny',
724
+ judgmentType: 'DEFAULT_DENY_UNKNOWN',
725
+ reason: `No static rule or allowlist match for host '${target.host || target.url}'`,
726
+ target,
727
+ latencyMs: Date.now() - started,
728
+ mode,
729
+ ruleId: 'default_deny_unknown',
730
+ });
731
+ }
732
+
733
+ /**
734
+ * CrabTrap-compatible response shape for competitor parity tests / adapters.
735
+ */
736
+ function evaluateCrabTrapRequest(reqPayload = {}, policy = {}, options = {}) {
737
+ const result = evaluateEgressStaticOnly({
738
+ method: reqPayload.method,
739
+ url: reqPayload.url || reqPayload.path,
740
+ host: reqPayload.host,
741
+ headers: reqPayload.headers,
742
+ body: reqPayload.body,
743
+ toolName: reqPayload.toolName,
744
+ agentId: reqPayload.agentId,
745
+ }, policy, options);
746
+
747
+ return {
748
+ action: result.action === 'allow' || result.action === 'observe' || result.action === 'observe_would_allow'
749
+ ? 'ALLOW'
750
+ : 'BLOCK',
751
+ status: result.status,
752
+ reason: result.reason,
753
+ judgmentType: result.judgmentType === 'STATIC_ALLOW'
754
+ || result.judgmentType === 'STATIC_ALLOW_BRIDGE'
755
+ || result.judgmentType === 'STATIC_DENY'
756
+ ? 'STATIC_RULE_MATCH'
757
+ : result.judgmentType,
758
+ ruleId: result.ruleId,
759
+ latencyMs: result.latencyMs,
760
+ interdictionSource: result.interdictionSource,
761
+ thumbgate: result,
762
+ };
763
+ }
764
+
765
+ /**
766
+ * Append an observe-mode audit entry (JSONL).
767
+ */
768
+ function observeEgress(request, options = {}) {
769
+ const target = parseTarget(request);
770
+ const entry = {
771
+ id: `obs_${crypto.randomBytes(8).toString('hex')}`,
772
+ at: nowIso(),
773
+ method: target.method,
774
+ url: target.url,
775
+ host: target.host,
776
+ toolName: target.toolName,
777
+ agentId: target.agentId || options.agentId || 'default',
778
+ principalId: target.principalId,
779
+ };
780
+
781
+ const ledgerPath = options.ledgerPath
782
+ || process.env.THUMBGATE_EGRESS_LEDGER
783
+ || path.join(process.cwd(), '.thumbgate', 'egress-observe.jsonl');
784
+
785
+ if (options.persist !== false) {
786
+ try {
787
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
788
+ fs.appendFileSync(ledgerPath, `${JSON.stringify(entry)}\n`, 'utf8');
789
+ } catch {
790
+ // never throw from observe
791
+ }
792
+ }
793
+
794
+ return entry;
795
+ }
796
+
797
+ function readObserveLedger(ledgerPath) {
798
+ const p = ledgerPath
799
+ || process.env.THUMBGATE_EGRESS_LEDGER
800
+ || path.join(process.cwd(), '.thumbgate', 'egress-observe.jsonl');
801
+ if (!fs.existsSync(p)) return [];
802
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
803
+ const out = [];
804
+ for (const line of lines) {
805
+ try {
806
+ out.push(JSON.parse(line));
807
+ } catch {
808
+ // skip corrupt
809
+ }
810
+ }
811
+ return out;
812
+ }
813
+
814
+ /**
815
+ * Draft a static policy from observed traffic (CrabTrap "observe then infer").
816
+ * Hosts seen ≥ minCount become allowlist + prefix allow rules.
817
+ * Private hosts become explicit denies.
818
+ */
819
+ function draftPolicyFromObservations(observations = [], options = {}) {
820
+ const minCount = Math.max(1, Number(options.minCount) || 2);
821
+ const agentId = options.agentId || 'default';
822
+ const counts = new Map();
823
+ // Agent identity separation: only count observations for the requested agent
824
+ // (unless agentId is explicitly '*' or filterAgent is false).
825
+ const filterAgent = options.filterAgent !== false && agentId !== '*';
826
+ let scopedObservationCount = 0;
827
+
828
+ for (const obs of observations) {
829
+ if (filterAgent) {
830
+ const obsAgent = String(obs.agentId || options.defaultAgentId || 'default');
831
+ if (obsAgent !== agentId) continue;
832
+ }
833
+ scopedObservationCount += 1;
834
+ const host = String(obs.host || parseTarget(obs).host || '').toLowerCase();
835
+ if (!host) continue;
836
+ counts.set(host, (counts.get(host) || 0) + 1);
837
+ }
838
+
839
+ const allowHosts = [];
840
+ const bridgeHosts = [];
841
+ const denyHosts = [];
842
+ const staticRules = [
843
+ { id: 'deny-metadata', action: 'deny', match: 'prefix', url: 'http://169.254.169.254' },
844
+ { id: 'deny-metadata-https', action: 'deny', match: 'prefix', url: 'https://169.254.169.254' },
845
+ ];
846
+
847
+ for (const [host, count] of counts.entries()) {
848
+ if (isPrivateOrLinkLocalHost(host)) {
849
+ denyHosts.push({ host, count });
850
+ staticRules.push({
851
+ id: `deny-observed-private-${host.replace(/[^a-z0-9.-]/gi, '_')}`,
852
+ action: 'deny',
853
+ match: 'host',
854
+ url: host,
855
+ });
856
+ continue;
857
+ }
858
+ if (count >= minCount) {
859
+ if (classifyHostRole(host).role === 'bridge') {
860
+ bridgeHosts.push(host);
861
+ continue;
862
+ }
863
+ allowHosts.push(host);
864
+ staticRules.push({
865
+ id: `allow-observed-${host.replace(/[^a-z0-9.-]/gi, '_')}`,
866
+ action: 'allow',
867
+ match: 'host',
868
+ url: host,
869
+ });
870
+ }
871
+ }
872
+
873
+ allowHosts.sort((a, b) => String(a).localeCompare(String(b)));
874
+ bridgeHosts.sort((a, b) => String(a).localeCompare(String(b)));
875
+ const naturalLanguagePolicy = [
876
+ `Agent ${agentId} may call only these hosts observed in production traffic: ${allowHosts.join(', ') || '(none yet)'}.`,
877
+ bridgeHosts.length
878
+ ? `Bridge hops (registries/proxies/HF, not trust boundaries): ${bridgeHosts.join(', ')}.`
879
+ : '',
880
+ 'Deny all private networks, link-local, and cloud metadata endpoints.',
881
+ 'Deny unknown public hosts until explicitly allowlisted.',
882
+ 'Never treat an allowlisted package proxy as a credential destination.',
883
+ options.extraPolicyText || '',
884
+ ].filter(Boolean).join(' ');
885
+
886
+ return {
887
+ agentId,
888
+ mode: 'enforce',
889
+ fallback: 'deny',
890
+ naturalLanguagePolicy,
891
+ allowHosts,
892
+ bridgeHosts,
893
+ staticRules,
894
+ stats: {
895
+ observationCount: scopedObservationCount,
896
+ totalObservations: observations.length,
897
+ uniqueHosts: counts.size,
898
+ allowHostCount: allowHosts.length,
899
+ bridgeHostCount: bridgeHosts.length,
900
+ denyHostCount: denyHosts.length,
901
+ minCount,
902
+ agentScoped: filterAgent,
903
+ },
904
+ draftedAt: nowIso(),
905
+ source: 'observe_then_infer',
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Replay audit/observe entries against a draft policy (CrabTrap eval system).
911
+ * Reports agreement with original decisions when present, else projected action.
912
+ */
913
+ function replayPolicy(entries = [], policy = {}, options = {}) {
914
+ const results = [];
915
+ let wouldAllow = 0;
916
+ let wouldDeny = 0;
917
+ let agreement = 0;
918
+ let comparable = 0;
919
+
920
+ for (const entry of entries) {
921
+ const projected = evaluateEgressStaticOnly(entry, policy, { mode: 'enforce' });
922
+ const original = entry.originalAction || entry.action || entry.decision || null;
923
+ let agrees = null;
924
+ if (original) {
925
+ comparable += 1;
926
+ const origAllow = ['allow', 'ALLOW', 'observe_would_allow', 'observe'].includes(original);
927
+ const projAllow = projected.action === 'allow';
928
+ agrees = origAllow === projAllow;
929
+ if (agrees) agreement += 1;
930
+ }
931
+ if (projected.action === 'allow') wouldAllow += 1;
932
+ else wouldDeny += 1;
933
+
934
+ results.push({
935
+ host: projected.target.host,
936
+ url: projected.target.url,
937
+ projectedAction: projected.action,
938
+ judgmentType: projected.judgmentType,
939
+ ruleId: projected.ruleId,
940
+ originalAction: original,
941
+ agrees,
942
+ });
943
+ }
944
+
945
+ return {
946
+ total: entries.length,
947
+ wouldAllow,
948
+ wouldDeny,
949
+ comparable,
950
+ agreement,
951
+ agreementRate: comparable ? agreement / comparable : null,
952
+ results: options.includeResults === false ? undefined : results,
953
+ policyAgentId: policy.agentId || null,
954
+ evaluatedAt: nowIso(),
955
+ };
956
+ }
957
+
958
+ /** Network-capable CLIs that must not pass when destination cannot be resolved. */
959
+ const NETWORK_CLI_NAMES = new Set([
960
+ 'curl', 'wget', 'fetch', 'httpie', 'http', 'scp', 'sftp',
961
+ 'nc', 'ncat', 'netcat', 'ssh', 'telnet', 'ftp', 'aria2c', 'axel',
962
+ ]);
963
+ const BARE_DEST_CLI_NAMES = new Set([
964
+ 'curl', 'wget', 'fetch', 'httpie', 'http', 'aria2c', 'axel',
965
+ ]);
966
+
967
+ function commandHasNetworkCli(command) {
968
+ const tokens = String(command || '').split(/\s+/);
969
+ for (const tok of tokens) {
970
+ const base = tok.replace(/^['"]|['"]$/g, '').split('/').pop().toLowerCase();
971
+ if (NETWORK_CLI_NAMES.has(base)) return true;
972
+ }
973
+ return false;
974
+ }
975
+
976
+ /**
977
+ * Token-scan for scheme-less destinations after curl/wget (no nested regex quantifiers).
978
+ * Example: `curl -s 169.254.169.254/latest/meta-data/`
979
+ */
980
+ function extractBareDestinationAfterCli(command = '') {
981
+ const tokens = String(command || '').split(/\s+/);
982
+ let seenCli = false;
983
+ for (const raw of tokens) {
984
+ const tok = raw.replace(/^['"]|['"]$/g, '');
985
+ if (!tok) continue;
986
+ if (!seenCli) {
987
+ const base = tok.split('/').pop().toLowerCase();
988
+ if (BARE_DEST_CLI_NAMES.has(base)) seenCli = true;
989
+ continue;
990
+ }
991
+ if (tok.startsWith('-')) continue; // flags / long options
992
+ // First non-flag token is the destination candidate
993
+ if (
994
+ /^https?:\/\//i.test(tok)
995
+ || /^(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\/|$)/.test(tok)
996
+ || /^\[?[0-9a-f:]+\]?(?::\d+)?(?:\/|$)/i.test(tok)
997
+ || /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?(?:\/|$)/i.test(tok)
998
+ ) {
999
+ return tok;
1000
+ }
1001
+ // Variable / unresolved token still counts as "seen" network intent
1002
+ return null;
1003
+ }
1004
+ return null;
1005
+ }
1006
+
1007
+ /**
1008
+ * Extract egress targets from a Bash command string (curl/wget/fetch/scp).
1009
+ * Also captures scheme-less hosts commonly used with curl (metadata IP paths).
1010
+ */
1011
+ function extractEgressFromCommand(command = '') {
1012
+ const cmd = String(command || '');
1013
+ const found = [];
1014
+ const methodMatch = cmd.match(/(?:^|\s)-X\s+([A-Z]+)/i);
1015
+ const method = methodMatch ? methodMatch[1] : 'GET';
1016
+ const urlRe = /https?:\/\/[^\s"'\\]+/gi;
1017
+ let m;
1018
+ while ((m = urlRe.exec(cmd)) !== null) {
1019
+ found.push(parseTarget({ url: m[0], method }));
1020
+ }
1021
+ // scp user@host:path — linear scan tokens after scp
1022
+ const tokens = cmd.split(/\s+/);
1023
+ for (let i = 0; i < tokens.length; i += 1) {
1024
+ const base = tokens[i].replace(/^['"]|['"]$/g, '').split('/').pop().toLowerCase();
1025
+ if (base !== 'scp') continue;
1026
+ for (let j = i + 1; j < tokens.length; j += 1) {
1027
+ const t = tokens[j].replace(/^['"]|['"]$/g, '');
1028
+ if (t.startsWith('-')) continue;
1029
+ const hostPart = t.includes('@') ? t.split('@').pop() : t;
1030
+ if (hostPart.includes(':')) {
1031
+ const host = hostPart.split(':')[0];
1032
+ if (host) found.push(parseTarget({ host, method: 'SCP' }));
1033
+ }
1034
+ break;
1035
+ }
1036
+ }
1037
+ // Scheme-less host/path after network CLI: curl 169.254.169.254/latest/...
1038
+ if (found.length === 0 && commandHasNetworkCli(cmd)) {
1039
+ const hostish = extractBareDestinationAfterCli(cmd);
1040
+ if (hostish) {
1041
+ const asUrl = /^https?:\/\//i.test(hostish) ? hostish : `http://${hostish}`;
1042
+ found.push(parseTarget({ url: asUrl, method }));
1043
+ }
1044
+ }
1045
+ return found;
1046
+ }
1047
+
1048
+ /**
1049
+ * Evaluate Bash tool input against egress policy (PreToolUse helper).
1050
+ * Fail-closed when a network CLI is present but destination cannot be resolved.
1051
+ */
1052
+ function evaluateBashEgress(command, policy = {}, options = {}) {
1053
+ const targets = extractEgressFromCommand(command);
1054
+ if (targets.length === 0) {
1055
+ if (commandHasNetworkCli(String(command || ''))) {
1056
+ return {
1057
+ allowed: false,
1058
+ action: 'deny',
1059
+ judgmentType: 'UNRESOLVED_BASH_EGRESS',
1060
+ reason: 'Network-capable command with unresolved destination — fail closed (no NO_EGRESS allow)',
1061
+ targets: [],
1062
+ };
1063
+ }
1064
+ return {
1065
+ allowed: true,
1066
+ action: 'allow',
1067
+ judgmentType: 'NO_EGRESS',
1068
+ reason: 'No network egress target detected in command',
1069
+ targets: [],
1070
+ };
1071
+ }
1072
+ const decisions = targets.map((t) => evaluateEgressStaticOnly({ ...t, toolName: 'Bash' }, policy, options));
1073
+ const denied = decisions.find((d) => d.action === 'deny' || d.action === 'observe_would_deny');
1074
+ if (denied) {
1075
+ return {
1076
+ allowed: false,
1077
+ action: denied.action,
1078
+ judgmentType: denied.judgmentType,
1079
+ reason: denied.reason,
1080
+ ruleId: denied.ruleId,
1081
+ targets: decisions,
1082
+ };
1083
+ }
1084
+ return {
1085
+ allowed: true,
1086
+ action: decisions[0].action,
1087
+ judgmentType: decisions[0].judgmentType,
1088
+ reason: decisions.map((d) => d.reason).join('; '),
1089
+ targets: decisions,
1090
+ };
1091
+ }
1092
+
1093
+ module.exports = {
1094
+ DEFAULT_AGENT_POLICY,
1095
+ DEFAULT_HEADER_CAP,
1096
+ DEFAULT_BODY_CAP,
1097
+ BRIDGE_HOST_SUFFIXES,
1098
+ parseTarget,
1099
+ isPrivateOrLinkLocalHost,
1100
+ classifyHostRole,
1101
+ requestCarriesSecrets,
1102
+ matchStaticRules,
1103
+ compileRule,
1104
+ buildJudgeSafeRequestView,
1105
+ evaluateEgress,
1106
+ evaluateEgressSync,
1107
+ evaluateEgressStaticOnly,
1108
+ evaluateAllowlistHit,
1109
+ evaluateCrabTrapRequest,
1110
+ observeEgress,
1111
+ readObserveLedger,
1112
+ draftPolicyFromObservations,
1113
+ replayPolicy,
1114
+ extractEgressFromCommand,
1115
+ evaluateBashEgress,
1116
+ hostAllowlisted,
1117
+ };