@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,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
+ }