@monster-orange/den 0.0.0 → 0.1.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 (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * Scoped HTTP custom tool for journey agents (@cursor/sdk custom-tool
3
+ * surface). The agent gets a single `http_request` tool whose reach is
4
+ * restricted to allowlisted base URLs; the harness injects
5
+ * `Authorization: Bearer <jwt>` (plus any session-derived scoping headers)
6
+ * inside execute() so the credential never enters the prompt, the tool
7
+ * args, results, or the transcript. The token and headers are derived from
8
+ * the real browser session (see tools/browser-session.ts).
9
+ *
10
+ * 4xx/5xx responses are structured results (the agent is expected to read
11
+ * and react to them); only policy violations (allowlist, method
12
+ * capability, invalid args) and transport failures produce `isError`
13
+ * results — and even those never throw, so no stack trace can carry the
14
+ * token.
15
+ *
16
+ * Method-capability contract (review finding 4): the tool NEVER issues
17
+ * DELETE (cleanup is harness-only, via the entrypoint's teardown client
18
+ * over the run ledger), and each journey's allowed methods derive from
19
+ * its spec surface — browser-surface journeys get read-only GET when the
20
+ * tool is mounted at all; api/mixed journeys get GET/POST/PUT/PATCH. See
21
+ * {@link allowedMethodsForSurface}.
22
+ *
23
+ * Path-capability contract (review finding R2-1): mutating methods
24
+ * (POST/PUT/PATCH) additionally require an explicit per-journey
25
+ * capability grant — a {methods, pathPattern} pair from the spec's
26
+ * `http_capabilities` field, where `*` in the pattern matches exactly one
27
+ * path segment. A mutating request matching no grant is rejected with a
28
+ * typed CAPABILITY_NOT_GRANTED error BEFORE any fetch. The safe default
29
+ * (no grants) makes the tool read-only for mutations. GET stays
30
+ * origin-wide — reads are the tool's purpose. The verifier's probe client
31
+ * and the teardown client are harness-side and unaffected.
32
+ */
33
+
34
+ import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
35
+ import { RunLedger, sanitizePath, type HttpMethod } from "./ledger.js";
36
+
37
+ /** MCP wire name the tool is mounted under. */
38
+ export const HTTP_TOOL_NAME = "http_request";
39
+
40
+ // Response-body cap forwarded to the model. Bounds the token cost of one
41
+ // tool result; large payloads (file listings, long message histories) are
42
+ // truncated with an explicit flag so the agent knows to narrow its query.
43
+ export const DEFAULT_MAX_RESPONSE_BYTES = 32_768;
44
+
45
+ // Per-request transport cap applied to EVERY fetch even when no run
46
+ // deadline signal is provided (review finding 6) — a single hung request
47
+ // can never stall a journey indefinitely.
48
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
49
+
50
+ /** Every method the tool can even parse (validation boundary). */
51
+ const KNOWN_METHODS: ReadonlySet<string> = new Set([
52
+ "GET",
53
+ "HEAD",
54
+ "OPTIONS",
55
+ "POST",
56
+ "PUT",
57
+ "PATCH",
58
+ "DELETE",
59
+ ]);
60
+
61
+ /**
62
+ * Method-capability contract (review finding 4):
63
+ * - The agent-facing tool NEVER performs DELETE — cleanup is exclusively
64
+ * harness-side, via the entrypoint's teardown client over the run
65
+ * ledger. Passing DELETE in `allowedMethods` throws at build time.
66
+ * - Per-journey capability is derived from the spec's surface:
67
+ * browser-surface journeys get a read-only GET tool (if mounted at
68
+ * all); api/mixed journeys get GET/POST/PUT/PATCH.
69
+ */
70
+ export function allowedMethodsForSurface(
71
+ surface: "browser" | "api" | "mixed",
72
+ ): readonly HttpMethod[] {
73
+ return surface === "browser" ? ["GET"] : ["GET", "POST", "PUT", "PATCH"];
74
+ }
75
+
76
+ // ── Path capabilities (review finding R2-1) ─────────────────────────────────
77
+
78
+ /** Methods a capability grant may cover — GET is origin-wide by design and
79
+ * DELETE is harness-only, so neither is grantable. */
80
+ const GRANTABLE_METHODS: ReadonlySet<string> = new Set([
81
+ "POST",
82
+ "PUT",
83
+ "PATCH",
84
+ ]);
85
+
86
+ /**
87
+ * One journey-scoped mutating-capability grant (runner-side shape of the
88
+ * spec's `http_capabilities` entries).
89
+ */
90
+ export interface HttpCapabilityGrant {
91
+ /** Mutating methods the grant covers (POST/PUT/PATCH only). */
92
+ methods: readonly HttpMethod[];
93
+ /**
94
+ * Matched against the full request pathname; `*` matches exactly one
95
+ * path segment (e.g. `/api/v1/items/{*}/notes`, written without the
96
+ * braces in real patterns).
97
+ */
98
+ pathPattern: string;
99
+ }
100
+
101
+ function escapeRegExp(value: string): string {
102
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ }
104
+
105
+ /** Compile a grant's path pattern: `*` → one path segment, rest literal. */
106
+ function pathPatternToRegExp(pattern: string): RegExp {
107
+ const source = pattern.split("*").map(escapeRegExp).join("[^/]+");
108
+ return new RegExp(`^${source}$`);
109
+ }
110
+
111
+ /**
112
+ * Fail-closed canonicalization guard (round-3 review finding): WHATWG URL
113
+ * keeps `%2F`, `%5C`, and multi-layer encodings encoded in `pathname`, so
114
+ * grant wildcards (`[^/]+`) would treat them as one segment while a
115
+ * downstream server may decode them into extra `/`-separated segments —
116
+ * expanding a mutating request beyond the granted route. Returns true
117
+ * when any path segment hides structural characters behind
118
+ * percent-encoding: an encoded `/` or `\`, an encoded `.`/`..` dot
119
+ * segment, control characters, or malformed percent-encoding at any
120
+ * decode layer. Each layer is NFKC-normalized before structural checks,
121
+ * so compatibility characters such as fullwidth slashes and dots cannot
122
+ * bypass the guard. A literal percent (`%25` → `%`) is safe because it
123
+ * does not introduce another percent-escape sequence.
124
+ */
125
+ function hidesEncodedPathStructure(pathname: string): boolean {
126
+ for (const rawSegment of pathname.split("/")) {
127
+ let current = rawSegment;
128
+ for (;;) {
129
+ current = current.normalize("NFKC");
130
+ if (
131
+ /[/\\]/.test(current) ||
132
+ current === "." ||
133
+ current === ".." ||
134
+ [...current].some((character) => {
135
+ const code = character.charCodeAt(0);
136
+ return code <= 0x1f || code === 0x7f;
137
+ })
138
+ ) {
139
+ return true;
140
+ }
141
+ const hasEscape = /%[0-9A-Fa-f]{2}/.test(current);
142
+ if (!hasEscape) {
143
+ // A literal percent may result from decoding %25, but a raw or
144
+ // newly-exposed malformed percent escape must fail closed.
145
+ if (
146
+ current.includes("%") &&
147
+ (current === rawSegment ||
148
+ /%[^0-9A-Fa-f]|%[0-9A-Fa-f]$/.test(current))
149
+ ) {
150
+ return true;
151
+ }
152
+ break;
153
+ }
154
+ let next: string;
155
+ try {
156
+ next = decodeURIComponent(current);
157
+ } catch {
158
+ // A malformed escape can be exposed by a later decode layer
159
+ // (for example %25C0%25AF → %C0%AF). Refuse rather than assume
160
+ // downstream servers will treat it as a harmless literal.
161
+ return true;
162
+ }
163
+ current = next;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+
169
+ interface CompiledGrant {
170
+ methods: ReadonlySet<string>;
171
+ pattern: RegExp;
172
+ }
173
+
174
+ function compileGrants(
175
+ grants: readonly HttpCapabilityGrant[],
176
+ ): CompiledGrant[] {
177
+ return grants.map((grant) => {
178
+ for (const method of grant.methods) {
179
+ if (!GRANTABLE_METHODS.has(method)) {
180
+ throw new Error(
181
+ `HTTP capability grants may only cover POST/PUT/PATCH — got ` +
182
+ `"${method}" for pattern "${grant.pathPattern}". GET is ` +
183
+ "origin-wide by design and DELETE is harness-only (teardown).",
184
+ );
185
+ }
186
+ }
187
+ return {
188
+ methods: new Set(grant.methods),
189
+ pattern: pathPatternToRegExp(grant.pathPattern),
190
+ };
191
+ });
192
+ }
193
+
194
+ /** Structural subset of den's EnvironmentConfig this module needs. */
195
+ export interface HttpToolEnvBaseUrls {
196
+ baseUrls: {
197
+ web?: string;
198
+ api: string;
199
+ services: Record<string, string>;
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Allowlist for the HTTP tool: the API base URL plus every service base
205
+ * URL. `baseUrls.web` is deliberately excluded — the browser toolset owns
206
+ * the web surface; this tool is API-scoped.
207
+ */
208
+ export function allowedBaseUrlsFromEnv(env: HttpToolEnvBaseUrls): string[] {
209
+ return [env.baseUrls.api, ...Object.values(env.baseUrls.services)];
210
+ }
211
+
212
+ export interface ScopedHttpToolOptions {
213
+ /**
214
+ * Base URLs the tool may reach. Relative `path_or_url` values resolve
215
+ * against the first entry (the API base URL by convention).
216
+ */
217
+ allowedBaseUrls: string[];
218
+ /** Harness-held JWT source; the value never leaves execute(). */
219
+ tokenProvider: () => Promise<string>;
220
+ /**
221
+ * Optional extra-header source: every request carries these headers (e.g.
222
+ * the org or workspace id the app scopes API calls by — the app's
223
+ * `journeys.app.session_token.headers`). Derived from the browser session
224
+ * alongside the token; not secrets. Empty values are dropped.
225
+ */
226
+ headersProvider?: () => Promise<Record<string, string>>;
227
+ /** Ledger fed with every successful mutating call (source: http-tool). */
228
+ ledger: RunLedger;
229
+ /** Response-body byte cap; defaults to DEFAULT_MAX_RESPONSE_BYTES. */
230
+ maxResponseBytes?: number;
231
+ /**
232
+ * Methods this journey's tool may issue (see
233
+ * {@link allowedMethodsForSurface}). DELETE is never permitted and
234
+ * throws at build time. Defaults to the api/mixed surface set.
235
+ */
236
+ allowedMethods?: readonly HttpMethod[];
237
+ /**
238
+ * Journey-scoped mutating grants (review finding R2-1): a POST/PUT/
239
+ * PATCH request must match one grant's method + path pattern or it is
240
+ * rejected before fetching. Defaults to NO grants — read-only for
241
+ * mutations. GET needs no grant (origin-wide reads).
242
+ */
243
+ capabilities?: readonly HttpCapabilityGrant[];
244
+ /**
245
+ * Run-deadline AbortSignal (budgets.timeout_s); combined with the
246
+ * per-request timeout so in-flight fetches abort when the run's
247
+ * wall-clock budget expires.
248
+ */
249
+ signal?: AbortSignal;
250
+ /** Per-request transport cap; defaults to DEFAULT_REQUEST_TIMEOUT_MS. */
251
+ requestTimeoutMs?: number;
252
+ }
253
+
254
+ export type HttpToolErrorCode =
255
+ | "INVALID_ARGS"
256
+ | "METHOD_NOT_ALLOWED"
257
+ | "CAPABILITY_NOT_GRANTED"
258
+ | "HOST_NOT_ALLOWLISTED"
259
+ | "REQUEST_FAILED";
260
+
261
+ /** JSON payload serialized into the tool result's text content. */
262
+ export interface HttpToolPayload {
263
+ status?: number;
264
+ headers?: Record<string, string>;
265
+ body?: unknown;
266
+ truncated?: boolean;
267
+ error?: { code: HttpToolErrorCode; message: string };
268
+ }
269
+
270
+ /** Tool result shape returned by execute(). */
271
+ export interface HttpToolResult {
272
+ content: Array<{ type: "text"; text: string }>;
273
+ isError?: boolean;
274
+ }
275
+
276
+ function textResult(payload: HttpToolPayload, isError = false): HttpToolResult {
277
+ const result: HttpToolResult = {
278
+ content: [{ type: "text", text: JSON.stringify(payload) }],
279
+ };
280
+ if (isError) {
281
+ result.isError = true;
282
+ }
283
+ return result;
284
+ }
285
+
286
+ function errorResult(code: HttpToolErrorCode, message: string): HttpToolResult {
287
+ return textResult({ error: { code, message } }, true);
288
+ }
289
+
290
+ /**
291
+ * Resolve the requested path or URL against the allowlist. Returns the
292
+ * final URL when permitted; undefined when the target host is not
293
+ * allowlisted. Matching is origin-exact against each allowed base URL, so
294
+ * lookalike hosts (e.g. `localhost:8000.evil.example.com`) never pass.
295
+ */
296
+ function resolveAllowlistedUrl(
297
+ pathOrUrl: string,
298
+ allowedBaseUrls: string[],
299
+ ): URL | undefined {
300
+ const isAbsolute = /^https?:\/\//i.test(pathOrUrl);
301
+ const target = isAbsolute
302
+ ? new URL(pathOrUrl)
303
+ : new URL(pathOrUrl, allowedBaseUrls[0]);
304
+
305
+ for (const base of allowedBaseUrls) {
306
+ if (target.origin === new URL(base).origin) {
307
+ return target;
308
+ }
309
+ }
310
+ return undefined;
311
+ }
312
+
313
+ /**
314
+ * Resolve an optional extra-header source: trimmed values, empties dropped.
315
+ * Shared by the scoped HTTP tool and the verifier's probe client.
316
+ */
317
+ export async function resolveExtraHeaders(
318
+ provider: (() => Promise<Record<string, string>>) | undefined,
319
+ ): Promise<Record<string, string>> {
320
+ if (provider === undefined) return {};
321
+ const out: Record<string, string> = {};
322
+ for (const [name, value] of Object.entries(await provider())) {
323
+ const trimmed = value.trim();
324
+ if (trimmed.length > 0) out[name] = trimmed;
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * Build the scoped HTTP custom tool. Mount under {@link HTTP_TOOL_NAME} in
331
+ * `Agent.create({local: {customTools}})`.
332
+ */
333
+ export function buildScopedHttpTool(
334
+ options: ScopedHttpToolOptions,
335
+ ): SDKCustomTool {
336
+ const {
337
+ allowedBaseUrls,
338
+ tokenProvider,
339
+ headersProvider,
340
+ ledger,
341
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
342
+ allowedMethods = allowedMethodsForSurface("api"),
343
+ capabilities = [],
344
+ signal: deadlineSignal,
345
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
346
+ } = options;
347
+ if (allowedBaseUrls.length === 0) {
348
+ throw new Error(
349
+ "buildScopedHttpTool requires at least one allowlisted base URL",
350
+ );
351
+ }
352
+ if (allowedMethods.includes("DELETE")) {
353
+ throw new Error(
354
+ "The agent-facing HTTP tool never performs DELETE — cleanup is " +
355
+ "harness-only via the teardown client. Remove DELETE from " +
356
+ "allowedMethods.",
357
+ );
358
+ }
359
+ const allowedMethodSet: ReadonlySet<string> = new Set(allowedMethods);
360
+ const grants = compileGrants(capabilities);
361
+ const grantedMethods = new Set(grants.flatMap((grant) => [...grant.methods]));
362
+ // Only advertise methods the tool can actually honor: reads plus the
363
+ // mutating methods at least one grant covers (review finding R2-1).
364
+ const advertisedMethods = allowedMethods.filter(
365
+ (method) => !GRANTABLE_METHODS.has(method) || grantedMethods.has(method),
366
+ );
367
+
368
+ return {
369
+ description:
370
+ "Make an authenticated HTTP request to the app's API. Only " +
371
+ "allowlisted API base URLs are reachable; authentication is " +
372
+ "handled for you — never supply credentials. Relative paths resolve " +
373
+ "against the primary API base URL.",
374
+ inputSchema: {
375
+ type: "object",
376
+ properties: {
377
+ method: {
378
+ type: "string",
379
+ enum: [...advertisedMethods],
380
+ description: "HTTP method (only the listed methods are permitted)",
381
+ },
382
+ path_or_url: {
383
+ type: "string",
384
+ description:
385
+ "API path (e.g. /api/v1/items) or absolute URL under an " +
386
+ "allowlisted base URL",
387
+ },
388
+ body: {
389
+ type: "object",
390
+ description: "Optional JSON request body",
391
+ },
392
+ query: {
393
+ type: "object",
394
+ description: "Optional query parameters (string values)",
395
+ additionalProperties: { type: "string" },
396
+ },
397
+ },
398
+ required: ["method", "path_or_url"],
399
+ additionalProperties: false,
400
+ },
401
+ execute: async (args: Record<string, SDKJsonValue>) => {
402
+ const method = typeof args.method === "string" ? args.method : "";
403
+ const pathOrUrl =
404
+ typeof args.path_or_url === "string" ? args.path_or_url : "";
405
+
406
+ if (!KNOWN_METHODS.has(method)) {
407
+ return errorResult(
408
+ "INVALID_ARGS",
409
+ `Unsupported method "${method}". Allowed: ${[...advertisedMethods].join(", ")}.`,
410
+ );
411
+ }
412
+ if (method === "DELETE") {
413
+ return errorResult(
414
+ "METHOD_NOT_ALLOWED",
415
+ "DELETE is never available to journey agents — resource cleanup " +
416
+ "is performed harness-side by the run's teardown over the " +
417
+ "resource ledger.",
418
+ );
419
+ }
420
+ if (!allowedMethodSet.has(method)) {
421
+ return errorResult(
422
+ "METHOD_NOT_ALLOWED",
423
+ `Method "${method}" is not permitted for this journey. ` +
424
+ `Allowed: ${[...advertisedMethods].join(", ")}.`,
425
+ );
426
+ }
427
+ if (pathOrUrl.length === 0) {
428
+ return errorResult("INVALID_ARGS", "path_or_url is required.");
429
+ }
430
+
431
+ let url: URL | undefined;
432
+ try {
433
+ url = resolveAllowlistedUrl(pathOrUrl, allowedBaseUrls);
434
+ } catch {
435
+ return errorResult("INVALID_ARGS", `Malformed URL: ${pathOrUrl}`);
436
+ }
437
+ if (url === undefined) {
438
+ return errorResult(
439
+ "HOST_NOT_ALLOWLISTED",
440
+ `Host "${new URL(pathOrUrl, allowedBaseUrls[0]).host}" is not an ` +
441
+ "allowlisted API base URL.",
442
+ );
443
+ }
444
+
445
+ // Journey-scoped path capability (review finding R2-1): a mutating
446
+ // request must match a grant's method + path pattern — checked on
447
+ // the bare pathname, BEFORE any fetch.
448
+ if (GRANTABLE_METHODS.has(method)) {
449
+ // Decode-and-compare canonicalization (round-3 finding): reject
450
+ // percent-encoded separators / dot-segment tricks BEFORE grant
451
+ // matching and any fetch. GET stays as-is — path tricks cannot
452
+ // change the origin, and reads are origin-wide by design.
453
+ if (hidesEncodedPathStructure(url.pathname)) {
454
+ return errorResult(
455
+ "CAPABILITY_NOT_GRANTED",
456
+ `${method} ${url.pathname} contains percent-encoded path ` +
457
+ "structure (encoded separators or dot segments) that could " +
458
+ "expand beyond the granted route after server-side " +
459
+ "decoding. Use literal, singly-encoded path segments.",
460
+ );
461
+ }
462
+ const granted = grants.some(
463
+ (grant) =>
464
+ grant.methods.has(method) && grant.pattern.test(url.pathname),
465
+ );
466
+ if (!granted) {
467
+ return errorResult(
468
+ "CAPABILITY_NOT_GRANTED",
469
+ `${method} ${url.pathname} matches no capability grant for ` +
470
+ "this journey. Mutations are limited to the journey's " +
471
+ "declared http_capabilities; everything else is read-only.",
472
+ );
473
+ }
474
+ }
475
+
476
+ const query = args.query;
477
+ if (
478
+ typeof query === "object" &&
479
+ query !== null &&
480
+ !Array.isArray(query)
481
+ ) {
482
+ for (const [key, value] of Object.entries(query)) {
483
+ if (typeof value === "string") {
484
+ url.searchParams.append(key, value);
485
+ }
486
+ }
487
+ }
488
+
489
+ const headers: Record<string, string> = {
490
+ ...(await resolveExtraHeaders(headersProvider)),
491
+ Authorization: `Bearer ${await tokenProvider()}`,
492
+ };
493
+ // Every fetch is bounded: per-request timeout always, plus the
494
+ // run-deadline signal when the runner provided one (finding 6).
495
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
496
+ const init: RequestInit = {
497
+ method,
498
+ headers,
499
+ signal: deadlineSignal
500
+ ? AbortSignal.any([deadlineSignal, timeoutSignal])
501
+ : timeoutSignal,
502
+ };
503
+ const body = args.body;
504
+ if (body !== undefined && method !== "GET" && method !== "HEAD") {
505
+ headers["Content-Type"] = "application/json";
506
+ init.body = JSON.stringify(body);
507
+ }
508
+
509
+ let response: Response;
510
+ try {
511
+ response = await fetch(url.toString(), init);
512
+ } catch (error) {
513
+ // Transport failures never throw out of the tool: a thrown error's
514
+ // message/stack could carry request internals. Report a scrubbed,
515
+ // structured failure instead.
516
+ const message = error instanceof Error ? error.message : String(error);
517
+ const token = headers.Authorization;
518
+ return errorResult(
519
+ "REQUEST_FAILED",
520
+ `Request to ${sanitizePath(url.toString())} failed: ` +
521
+ message.split(token).join("[REDACTED]"),
522
+ );
523
+ }
524
+
525
+ const rawBody = await response.text();
526
+ const truncated = Buffer.byteLength(rawBody, "utf8") > maxResponseBytes;
527
+ const cappedBody = truncated
528
+ ? rawBody.slice(0, maxResponseBytes)
529
+ : rawBody;
530
+
531
+ const contentType = response.headers.get("content-type") ?? "";
532
+ let parsedBody: unknown = cappedBody;
533
+ if (!truncated && contentType.includes("application/json")) {
534
+ try {
535
+ parsedBody = JSON.parse(rawBody);
536
+ } catch {
537
+ parsedBody = cappedBody;
538
+ }
539
+ }
540
+
541
+ ledger.record(
542
+ {
543
+ method: method as HttpMethod,
544
+ pathOrUrl: url.toString(),
545
+ status: response.status,
546
+ body: parsedBody,
547
+ },
548
+ "http-tool",
549
+ );
550
+
551
+ const payload: HttpToolPayload = {
552
+ status: response.status,
553
+ // Header subset by construction: only content-type is ever exposed.
554
+ // Set-Cookie, WWW-Authenticate, and friends stay harness-side.
555
+ headers: contentType ? { "content-type": contentType } : {},
556
+ body: parsedBody,
557
+ };
558
+ if (truncated) {
559
+ payload.truncated = true;
560
+ }
561
+ return textResult(payload);
562
+ },
563
+ };
564
+ }