@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.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- 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 +99 -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,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journeys sandbox + credential isolation (plan: jrn-runner packet,
|
|
3
|
+
* Approach §Credential isolation — four ordered layers).
|
|
4
|
+
*
|
|
5
|
+
* Layer 1 — captureSecrets(): CURSOR_API_KEY (env var when set — CI; else
|
|
6
|
+
* the secrets backend, at the `journeys_agent_key` named ref — see
|
|
7
|
+
* namedSecretRef: `secrets.refs.journeys_agent_key` in the org config,
|
|
8
|
+
* field label = env-var name). The Cursor key is held in memory only;
|
|
9
|
+
* nothing is written to disk and process.env is not mutated. The API
|
|
10
|
+
* assertion token is NOT minted here; it is derived from the real browser
|
|
11
|
+
* session (deriveApiTokenFromSession) after provisionBrowserSession,
|
|
12
|
+
* giving navigation and assertions the same real-user token.
|
|
13
|
+
* Layer 2 — fail-closed shell denial: prepareSandbox() stages a cwd
|
|
14
|
+
* containing a generated .cursor/ tree — hooks.json (whose
|
|
15
|
+
* beforeShellExecution hook runs the checked-in deny script, copied
|
|
16
|
+
* into the staged dir so the path resolves from there, with
|
|
17
|
+
* `failClosed: true` — hooks are otherwise fail-open) plus
|
|
18
|
+
* permissions.json allowlisting only the custom-user-tools MCP server.
|
|
19
|
+
* This deny hook is the shell guard. The runner passes
|
|
20
|
+
* `settingSources: ["project"]` so this .cursor/ config is loaded;
|
|
21
|
+
* the OS sandbox (`sandboxOptions`) is DISABLED because it blocks the
|
|
22
|
+
* custom-tool MCP calls the journey depends on (and journeys need the
|
|
23
|
+
* network for browser navigation + API probes regardless).
|
|
24
|
+
* Layer 4 — TranscriptScrubber/TranscriptWriter: the persist path holds
|
|
25
|
+
* the set of live secret values and fails the run loudly if any would
|
|
26
|
+
* be written (runner maps SecretLeakError to failure_kind=infra_error).
|
|
27
|
+
*
|
|
28
|
+
* ORDERING IS LOAD-BEARING: all secret-consuming provisioning
|
|
29
|
+
* (captureSecrets, provisionBrowserSession) must complete BEFORE
|
|
30
|
+
* sanitizeEnvironment() — which reduces the env to an explicit executor
|
|
31
|
+
* ALLOWLIST (denylist kept as belt and braces) — which must complete
|
|
32
|
+
* before Agent.create. The runner (runner.ts) owns that sequence and
|
|
33
|
+
* snapshots the env BEFORE captureSecrets so restore() never
|
|
34
|
+
* reintroduces secrets minted during capture; isolation tests pin it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from "fs";
|
|
38
|
+
import * as path from "path";
|
|
39
|
+
import { OP_API_KEYS_VAULT, OP_DEV_KEYS_ITEM } from "../../shared/constants.js";
|
|
40
|
+
import {
|
|
41
|
+
getSecretsProvider,
|
|
42
|
+
namedSecretRef,
|
|
43
|
+
type SecretRef,
|
|
44
|
+
} from "../../shared/secrets/index.js";
|
|
45
|
+
import type { Environment } from "../../shared/types/index.js";
|
|
46
|
+
import type { JourneyPersonaRole } from "./spec/schema.js";
|
|
47
|
+
|
|
48
|
+
import { product } from "../../shared/product.js";
|
|
49
|
+
import { enginePath } from "../../shared/engine.js";
|
|
50
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
51
|
+
// ── Layer 1: captureSecrets ──────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** 1Password field label holding the Cursor API key (= env var name). */
|
|
54
|
+
export const CURSOR_API_KEY_FIELD = "CURSOR_API_KEY";
|
|
55
|
+
|
|
56
|
+
/** Name of the agent-key ref under `secrets.refs` in the org config. */
|
|
57
|
+
export const JOURNEYS_AGENT_KEY_REF = "journeys_agent_key";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the agent key lives when `secrets.refs.journeys_agent_key` is not
|
|
61
|
+
* configured: the engineer's own developer-keys item (neutral default).
|
|
62
|
+
*/
|
|
63
|
+
export const JOURNEYS_AGENT_KEY_DEFAULT: SecretRef = {
|
|
64
|
+
vault: OP_API_KEYS_VAULT,
|
|
65
|
+
item: OP_DEV_KEYS_ITEM,
|
|
66
|
+
field: CURSOR_API_KEY_FIELD,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Secrets captured into memory before environment sanitization.
|
|
71
|
+
*
|
|
72
|
+
* This carries no API/persona JWT: the API assertion token is derived from
|
|
73
|
+
* the real browser session (see deriveApiTokenFromSession) AFTER
|
|
74
|
+
* provisioning, so the runner never mints a token via a password grant.
|
|
75
|
+
* captureSecrets is solely responsible for the Cursor API key.
|
|
76
|
+
*/
|
|
77
|
+
export interface CapturedSecrets {
|
|
78
|
+
/** Passed explicitly to Agent.create — never re-read from process.env. */
|
|
79
|
+
cursorApiKey: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OpFieldRequest {
|
|
83
|
+
item: string;
|
|
84
|
+
vault: string;
|
|
85
|
+
field: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CaptureSecretsDeps {
|
|
89
|
+
/** Injectable `op` field reader (unit tests mock the CLI through this). */
|
|
90
|
+
opReadField?: (request: OpFieldRequest) => Promise<string>;
|
|
91
|
+
/** Env-var source; defaults to process.env. */
|
|
92
|
+
env?: NodeJS.ProcessEnv;
|
|
93
|
+
/** Where the key lives; defaults to the configured `journeys_agent_key` ref. */
|
|
94
|
+
agentKeyRef?: SecretRef;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Default reader: the configured secrets strategy. */
|
|
98
|
+
async function defaultOpReadField(request: OpFieldRequest): Promise<string> {
|
|
99
|
+
// Resolved through the configured secrets strategy (src/shared/secrets).
|
|
100
|
+
return getSecretsProvider().get(request);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Source CURSOR_API_KEY (env var first — CI; else the secrets backend),
|
|
105
|
+
* holding it in memory only. Fails loudly, naming both sources, when no key
|
|
106
|
+
* can be obtained. The API-assertion token is NOT minted here; it is
|
|
107
|
+
* derived from the browser session after provisioning.
|
|
108
|
+
*/
|
|
109
|
+
export async function captureSecrets(
|
|
110
|
+
_role: JourneyPersonaRole,
|
|
111
|
+
_env: Environment,
|
|
112
|
+
deps: CaptureSecretsDeps = {},
|
|
113
|
+
): Promise<CapturedSecrets> {
|
|
114
|
+
const { opReadField = defaultOpReadField, env: envVars = process.env } = deps;
|
|
115
|
+
|
|
116
|
+
let cursorApiKey = envVars[CURSOR_API_KEY_FIELD]?.trim() ?? "";
|
|
117
|
+
if (cursorApiKey.length === 0) {
|
|
118
|
+
let opError = "";
|
|
119
|
+
const ref =
|
|
120
|
+
deps.agentKeyRef ??
|
|
121
|
+
namedSecretRef(JOURNEYS_AGENT_KEY_REF, JOURNEYS_AGENT_KEY_DEFAULT);
|
|
122
|
+
try {
|
|
123
|
+
cursorApiKey = (
|
|
124
|
+
await opReadField({
|
|
125
|
+
item: ref.item,
|
|
126
|
+
vault: ref.vault,
|
|
127
|
+
field: ref.field,
|
|
128
|
+
})
|
|
129
|
+
).trim();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
opError = error instanceof Error ? error.message : String(error);
|
|
132
|
+
}
|
|
133
|
+
if (cursorApiKey.length === 0) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`No Cursor API key available for journey agents.\n` +
|
|
136
|
+
` Options:\n` +
|
|
137
|
+
` • CI: set the ${CURSOR_API_KEY_FIELD} env var\n` +
|
|
138
|
+
` • Local: sign in to the secrets backend (make ${product().cli}-setup-secrets) and\n` +
|
|
139
|
+
` make sure the "${ref.item}" item in the "${ref.vault}" vault has a\n` +
|
|
140
|
+
` ${ref.field} field (move it with secrets.refs.${JOURNEYS_AGENT_KEY_REF})\n` +
|
|
141
|
+
(opError ? ` Secrets backend error: ${opError}\n` : ""),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { cursorApiKey };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Sanitize environment ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Secret-bearing vars that must NEVER survive into the executor env, even
|
|
153
|
+
* if a future allowlist entry would otherwise admit them (belt and
|
|
154
|
+
* braces over the allowlist below). Exact names plus prefix/pattern
|
|
155
|
+
* classes.
|
|
156
|
+
*/
|
|
157
|
+
const SANITIZED_EXACT: readonly string[] = [CURSOR_API_KEY_FIELD];
|
|
158
|
+
const SANITIZED_PATTERNS: readonly RegExp[] = [
|
|
159
|
+
/^E2E_/,
|
|
160
|
+
/^SMOKE_.*_TOKEN$/,
|
|
161
|
+
/^AUTH0_/,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Executor env ALLOWLIST: everything NOT matching is removed before
|
|
166
|
+
* Agent.create (the local executor and tool processes inherit den's
|
|
167
|
+
* process env at spawn). Denylist wins over allowlist. Documented
|
|
168
|
+
* classes:
|
|
169
|
+
* - process/OS basics: PATH, HOME, TMPDIR/TMP/TEMP, SHELL, USER,
|
|
170
|
+
* LOGNAME, LANG, LC_*, TZ, TERM, COLORTERM;
|
|
171
|
+
* - Node/npm toolchain: NODE_*, NVM_*, npm_* (tsx/node subprocesses);
|
|
172
|
+
* - journeys operator config: JOURNEYS_* (non-secret by contract —
|
|
173
|
+
* e.g. JOURNEYS_MODEL).
|
|
174
|
+
* Ambient credentials (AWS_*, SENTRY_*, GH_* / GITHUB_*, OP_SESSION_*,
|
|
175
|
+
* …) are excluded by construction rather than enumerated.
|
|
176
|
+
*/
|
|
177
|
+
export const EXECUTOR_ENV_ALLOWLIST_EXACT: readonly string[] = [
|
|
178
|
+
"PATH",
|
|
179
|
+
"HOME",
|
|
180
|
+
"TMPDIR",
|
|
181
|
+
"TMP",
|
|
182
|
+
"TEMP",
|
|
183
|
+
"SHELL",
|
|
184
|
+
"USER",
|
|
185
|
+
"LOGNAME",
|
|
186
|
+
"LANG",
|
|
187
|
+
"TZ",
|
|
188
|
+
"TERM",
|
|
189
|
+
"COLORTERM",
|
|
190
|
+
];
|
|
191
|
+
export const EXECUTOR_ENV_ALLOWLIST_PATTERNS: readonly RegExp[] = [
|
|
192
|
+
/^NODE_/,
|
|
193
|
+
/^NVM_/,
|
|
194
|
+
/^npm_/,
|
|
195
|
+
/^LC_/,
|
|
196
|
+
/^JOURNEYS_/,
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
function isDenylisted(key: string): boolean {
|
|
200
|
+
return (
|
|
201
|
+
SANITIZED_EXACT.includes(key) ||
|
|
202
|
+
SANITIZED_PATTERNS.some((pattern) => pattern.test(key))
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isAllowlisted(key: string): boolean {
|
|
207
|
+
return (
|
|
208
|
+
EXECUTOR_ENV_ALLOWLIST_EXACT.includes(key) ||
|
|
209
|
+
EXECUTOR_ENV_ALLOWLIST_PATTERNS.some((pattern) => pattern.test(key))
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface SanitizeResult {
|
|
214
|
+
/** Names of the vars that were removed. */
|
|
215
|
+
removed: readonly string[];
|
|
216
|
+
/** Restore every removed var (call in finally). */
|
|
217
|
+
restore(): void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reduce `env` (default process.env) to the executor allowlist, returning
|
|
222
|
+
* a restore handle. A key is removed when it is denylisted OR not
|
|
223
|
+
* allowlisted. The caller owns invoking restore() in a finally block.
|
|
224
|
+
*
|
|
225
|
+
* When `baseline` (a snapshot of the env taken BEFORE captureSecrets) is
|
|
226
|
+
* provided, restore() reinstates removed keys from the baseline instead
|
|
227
|
+
* of their pre-sanitize values — so a secret INTRODUCED during secret
|
|
228
|
+
* capture (e.g. getApiToken exporting E2E_AUTH0_CLIENT_SECRET) is never
|
|
229
|
+
* restored into the process env after the run.
|
|
230
|
+
*/
|
|
231
|
+
export function sanitizeEnvironment(
|
|
232
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
233
|
+
baseline?: Readonly<Record<string, string | undefined>>,
|
|
234
|
+
): SanitizeResult {
|
|
235
|
+
const saved = new Map<string, string>();
|
|
236
|
+
for (const key of Object.keys(env)) {
|
|
237
|
+
const remove = isDenylisted(key) || !isAllowlisted(key);
|
|
238
|
+
if (remove && env[key] !== undefined) {
|
|
239
|
+
saved.set(key, env[key] as string);
|
|
240
|
+
delete env[key];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
removed: [...saved.keys()],
|
|
245
|
+
restore(): void {
|
|
246
|
+
for (const [key, savedValue] of saved) {
|
|
247
|
+
const value = baseline ? baseline[key] : savedValue;
|
|
248
|
+
if (value !== undefined) {
|
|
249
|
+
env[key] = value;
|
|
250
|
+
} else {
|
|
251
|
+
delete env[key];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Layer 2: staged sandbox cwd + fail-closed deny hook ─────────────────────
|
|
259
|
+
|
|
260
|
+
/** Checked-in deny-hook script (copied into every staged sandbox). */
|
|
261
|
+
const DENY_SCRIPT_SOURCE = enginePath(
|
|
262
|
+
"src/pillars/journeys/hooks/deny-shell.cjs",
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
/** Relative (from the staged dir — project hooks run from the project root). */
|
|
266
|
+
const DENY_SCRIPT_RELATIVE = ".cursor/hooks/deny-shell.cjs";
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The SDK exposes `customTools` to the agent as an MCP server named
|
|
270
|
+
* `custom-user-tools` (see @cursor/sdk options.d.ts). We allowlist the
|
|
271
|
+
* whole server in permissions.json because our own harness tools
|
|
272
|
+
* (browser_* / http_request) are trusted by construction. With the OS
|
|
273
|
+
* sandbox disabled (see runner.ts) headless custom tools already run
|
|
274
|
+
* without approval, so this allowlist is belt-and-suspenders — it keeps
|
|
275
|
+
* the tools auto-approved if the sandbox / a Run Mode is ever re-enabled.
|
|
276
|
+
* Shell is deliberately NOT allowlisted (no `terminalAllowlist`) and
|
|
277
|
+
* stays denied by the beforeShellExecution hook below.
|
|
278
|
+
*/
|
|
279
|
+
const CUSTOM_TOOLS_MCP_ALLOWLIST = ["custom-user-tools:*"] as const;
|
|
280
|
+
|
|
281
|
+
export interface StagedSandbox {
|
|
282
|
+
/** Staged cwd passed to Agent.create({local: {cwd}}). */
|
|
283
|
+
dir: string;
|
|
284
|
+
hooksJsonPath: string;
|
|
285
|
+
/** Path of the generated `.cursor/permissions.json`. */
|
|
286
|
+
permissionsJsonPath: string;
|
|
287
|
+
/** Absolute path of the deny script copy inside the staged dir. */
|
|
288
|
+
denyScriptPath: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface PrepareSandboxOptions {
|
|
292
|
+
/** Parent directory for staged cwds; defaults to .journeys-run/. */
|
|
293
|
+
baseDir?: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Stage `<baseDir>/<journey>-<ts>/` containing the generated `.cursor/`
|
|
298
|
+
* config the SDK loads via `settingSources: ["project"]`:
|
|
299
|
+
* - `hooks.json` + the deny-script copy it references (shell denied,
|
|
300
|
+
* `failClosed: true` so shell is denied even if the script errors —
|
|
301
|
+
* hooks are fail-open by default, verified against
|
|
302
|
+
* cursor.com/docs/agent/hooks);
|
|
303
|
+
* - `permissions.json` allowlisting the harness's own `custom-user-tools`
|
|
304
|
+
* MCP server so the agent can call browser_* / http_request without an
|
|
305
|
+
* (impossible, headless) approval prompt while the sandbox Run Mode is
|
|
306
|
+
* active. Shell is deliberately absent from any allowlist.
|
|
307
|
+
*/
|
|
308
|
+
export function prepareSandbox(
|
|
309
|
+
journeyName: string,
|
|
310
|
+
options: PrepareSandboxOptions = {},
|
|
311
|
+
): StagedSandbox {
|
|
312
|
+
const baseDir =
|
|
313
|
+
options.baseDir ?? path.resolve(getDistRoot(), ".journeys-run");
|
|
314
|
+
const slug = journeyName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
315
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
316
|
+
const dir = path.join(baseDir, `${slug}-${timestamp}`);
|
|
317
|
+
|
|
318
|
+
const hooksDir = path.join(dir, ".cursor", "hooks");
|
|
319
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
320
|
+
|
|
321
|
+
const denyScriptPath = path.join(hooksDir, "deny-shell.cjs");
|
|
322
|
+
fs.copyFileSync(DENY_SCRIPT_SOURCE, denyScriptPath);
|
|
323
|
+
|
|
324
|
+
const hooksJsonPath = path.join(dir, ".cursor", "hooks.json");
|
|
325
|
+
const hooksConfig = {
|
|
326
|
+
version: 1,
|
|
327
|
+
hooks: {
|
|
328
|
+
beforeShellExecution: [
|
|
329
|
+
{
|
|
330
|
+
command: `node ${DENY_SCRIPT_RELATIVE}`,
|
|
331
|
+
failClosed: true,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
fs.writeFileSync(hooksJsonPath, `${JSON.stringify(hooksConfig, null, 2)}\n`);
|
|
337
|
+
|
|
338
|
+
const permissionsJsonPath = path.join(dir, ".cursor", "permissions.json");
|
|
339
|
+
const permissionsConfig = {
|
|
340
|
+
mcpAllowlist: [...CUSTOM_TOOLS_MCP_ALLOWLIST],
|
|
341
|
+
};
|
|
342
|
+
fs.writeFileSync(
|
|
343
|
+
permissionsJsonPath,
|
|
344
|
+
`${JSON.stringify(permissionsConfig, null, 2)}\n`,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return { dir, hooksJsonPath, permissionsJsonPath, denyScriptPath };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Layer 4: transcript scrubber ─────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** A live secret value was about to be persisted — the run must fail. */
|
|
353
|
+
export class SecretLeakError extends Error {
|
|
354
|
+
constructor() {
|
|
355
|
+
// Deliberately value-free: the error itself must not carry the secret.
|
|
356
|
+
super(
|
|
357
|
+
"Transcript persist blocked: content matched a live secret value. " +
|
|
358
|
+
"The journey fails closed (failure_kind=infra_error).",
|
|
359
|
+
);
|
|
360
|
+
this.name = "SecretLeakError";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Holds the set of live secret values for the run and refuses to let any
|
|
366
|
+
* of them be persisted. Empty values are ignored (they would match
|
|
367
|
+
* everything).
|
|
368
|
+
*/
|
|
369
|
+
export class TranscriptScrubber {
|
|
370
|
+
private readonly secrets: string[];
|
|
371
|
+
|
|
372
|
+
constructor(secrets: readonly string[]) {
|
|
373
|
+
this.secrets = secrets.filter((value) => value.length > 0);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Throws SecretLeakError when `text` contains any live secret value. */
|
|
377
|
+
assertClean(text: string): void {
|
|
378
|
+
for (const secret of this.secrets) {
|
|
379
|
+
if (text.includes(secret)) {
|
|
380
|
+
throw new SecretLeakError();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* JSONL transcript writer whose persist path runs through the scrubber:
|
|
388
|
+
* every serialized message is checked BEFORE it reaches disk, so a leak
|
|
389
|
+
* aborts the append rather than redacting after the fact.
|
|
390
|
+
*/
|
|
391
|
+
export class TranscriptWriter {
|
|
392
|
+
private readonly filePath: string;
|
|
393
|
+
private readonly scrubber: TranscriptScrubber;
|
|
394
|
+
|
|
395
|
+
constructor(filePath: string, scrubber: TranscriptScrubber) {
|
|
396
|
+
this.filePath = filePath;
|
|
397
|
+
this.scrubber = scrubber;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async append(message: unknown): Promise<void> {
|
|
401
|
+
const line = JSON.stringify(message);
|
|
402
|
+
this.scrubber.assertClean(line);
|
|
403
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
404
|
+
fs.appendFileSync(this.filePath, `${line}\n`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* *.journey.yml loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads a journey spec YAML file, validates it against journeySpecSchema,
|
|
5
|
+
* and returns a typed JourneySpec — or throws a JourneySpecError carrying
|
|
6
|
+
* the offending file path and manifest-style mapped validation errors
|
|
7
|
+
* (error mapping modeled on src/shared/manifest/validate-manifest.ts).
|
|
8
|
+
* Discovery relies on this throwing loudly: an invalid spec file must
|
|
9
|
+
* never be silently skipped.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as yaml from "yaml";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { journeySpecSchema } from "./schema.js";
|
|
16
|
+
import type { JourneySpec } from "./schema.js";
|
|
17
|
+
import type { ValidationError } from "../../../shared/types/index.js";
|
|
18
|
+
|
|
19
|
+
// ── Error type ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export class JourneySpecError extends Error {
|
|
22
|
+
readonly errors: ValidationError[];
|
|
23
|
+
readonly filePath?: string;
|
|
24
|
+
|
|
25
|
+
constructor(errors: ValidationError[], filePath?: string) {
|
|
26
|
+
const location = filePath ? ` at ${filePath}` : "";
|
|
27
|
+
const detail = errors
|
|
28
|
+
.map((e) => (e.path ? ` - ${e.path}: ${e.message}` : ` - ${e.message}`))
|
|
29
|
+
.join("\n");
|
|
30
|
+
super(`Invalid journey spec${location}:\n${detail}`);
|
|
31
|
+
this.name = "JourneySpecError";
|
|
32
|
+
this.errors = errors;
|
|
33
|
+
this.filePath = filePath;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Zod issue → ValidationError mapping ──────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Convert a Zod issue path to a dot-separated string (arrays as [n]). */
|
|
40
|
+
function zodPathToString(path: ReadonlyArray<PropertyKey>): string {
|
|
41
|
+
return path
|
|
42
|
+
.map((p, i) =>
|
|
43
|
+
typeof p === "number" ? `[${p}]` : i === 0 ? String(p) : `.${String(p)}`,
|
|
44
|
+
)
|
|
45
|
+
.join("");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Friendly message for common Zod issue codes. */
|
|
49
|
+
function zodIssueMessage(issue: z.core.$ZodIssue): string {
|
|
50
|
+
switch (issue.code) {
|
|
51
|
+
case "invalid_type":
|
|
52
|
+
if (issue.message.endsWith("received undefined")) {
|
|
53
|
+
return `"${String(issue.path.at(-1))}" is required`;
|
|
54
|
+
}
|
|
55
|
+
return issue.message;
|
|
56
|
+
case "invalid_value":
|
|
57
|
+
return `invalid value — must be one of: ${issue.values
|
|
58
|
+
.map((v) => String(v))
|
|
59
|
+
.join(", ")}`;
|
|
60
|
+
case "unrecognized_keys":
|
|
61
|
+
return `unrecognized key(s): ${issue.keys.join(", ")}`;
|
|
62
|
+
default:
|
|
63
|
+
return issue.message;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Flatten Zod issues into ValidationErrors, recursing into union branches
|
|
69
|
+
* so assertion problems surface as pointed per-field errors rather than a
|
|
70
|
+
* generic "invalid union" message.
|
|
71
|
+
*/
|
|
72
|
+
function collectIssues(
|
|
73
|
+
issues: ReadonlyArray<z.core.$ZodIssue>,
|
|
74
|
+
out: ValidationError[],
|
|
75
|
+
): void {
|
|
76
|
+
for (const issue of issues) {
|
|
77
|
+
if (issue.code === "invalid_union") {
|
|
78
|
+
const nested = issue.errors.flat();
|
|
79
|
+
if (nested.length > 0) {
|
|
80
|
+
collectIssues(nested, out);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
out.push({
|
|
85
|
+
path: zodPathToString(issue.path),
|
|
86
|
+
message: zodIssueMessage(issue),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Main exports ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse and validate a journey spec from a raw YAML string or an
|
|
95
|
+
* already-parsed object.
|
|
96
|
+
*
|
|
97
|
+
* @throws JourneySpecError on YAML parse failure or schema violation.
|
|
98
|
+
*/
|
|
99
|
+
export function parseJourneySpec(
|
|
100
|
+
input: unknown,
|
|
101
|
+
filePath?: string,
|
|
102
|
+
): JourneySpec {
|
|
103
|
+
let parsed: unknown = input;
|
|
104
|
+
if (typeof input === "string") {
|
|
105
|
+
try {
|
|
106
|
+
parsed = yaml.parse(input);
|
|
107
|
+
} catch (err: unknown) {
|
|
108
|
+
throw new JourneySpecError(
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
path: "",
|
|
112
|
+
message: `Could not parse YAML: ${(err as Error).message}`,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
filePath,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = journeySpecSchema.safeParse(parsed);
|
|
121
|
+
if (result.success) {
|
|
122
|
+
return result.data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const errors: ValidationError[] = [];
|
|
126
|
+
collectIssues(result.error.issues, errors);
|
|
127
|
+
throw new JourneySpecError(errors, filePath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load a *.journey.yml file from disk and validate it.
|
|
132
|
+
*
|
|
133
|
+
* @throws JourneySpecError when the file is unreadable, malformed YAML,
|
|
134
|
+
* or violates the journey spec schema.
|
|
135
|
+
*/
|
|
136
|
+
export function loadJourneySpec(filePath: string): JourneySpec {
|
|
137
|
+
let content: string;
|
|
138
|
+
try {
|
|
139
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
throw new JourneySpecError(
|
|
142
|
+
[
|
|
143
|
+
{
|
|
144
|
+
path: "",
|
|
145
|
+
message: `Could not read journey spec file: ${(err as Error).message}`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
filePath,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return parseJourneySpec(content, filePath);
|
|
152
|
+
}
|