@monster-orange/den 0.0.0-stage → 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.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +104 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- 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
|
+
}
|