@clipboard-health/groundcrew 4.12.0 → 4.14.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.
@@ -14,6 +14,7 @@ api.openai.com
14
14
  chatgpt.com
15
15
  docs.anthropic.com
16
16
  docs.claude.com
17
+ downloads.claude.ai
17
18
  platform.claude.com
18
19
 
19
20
  # Hosted MCP servers + their auth/web hosts
@@ -1 +1 @@
1
- {"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcnE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;CAChB;AA6HD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Df;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
1
+ {"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAenE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;CAChB;AA6HD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA0Ff;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
@@ -4,6 +4,7 @@ import { loadConfig } from "../lib/config.js";
4
4
  import { openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
5
5
  import { buildLaunchCommand } from "../lib/launchCommand.js";
6
6
  import { readRunState, recordRunState } from "../lib/runState.js";
7
+ import { buildAndStageSrtLaunch } from "../lib/srtLaunch.js";
7
8
  import { removeStagedPrompt, stageBuildSecrets, stagePromptText, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
8
9
  import { errorMessage, log } from "../lib/util.js";
9
10
  import { workspaces } from "../lib/workspaces.js";
@@ -119,6 +120,26 @@ export async function resumeWorkspace(config, options) {
119
120
  text: renderResumePrompt(context),
120
121
  });
121
122
  const secretsFile = stageBuildSecrets(stagedPrompt.directory);
123
+ // Resume must stage srt settings exactly like setup, or `buildLaunchCommand`
124
+ // throws under the srt runner — and a relocating agent (codex) needs its
125
+ // config home re-seeded so it authenticates on the resumed launch.
126
+ let srtPrepareSettingsFile;
127
+ let srtAgentSettingsFile;
128
+ let srtSettingsDir;
129
+ let srtAgentConfigDirEnv;
130
+ if (runner === "srt") {
131
+ const staged = buildAndStageSrtLaunch({
132
+ config,
133
+ repository: context.repository,
134
+ ticket,
135
+ worktreeDir: context.worktree.dir,
136
+ definition,
137
+ });
138
+ srtPrepareSettingsFile = staged.prepareFile;
139
+ srtAgentSettingsFile = staged.agentFile;
140
+ srtSettingsDir = staged.directory;
141
+ srtAgentConfigDirEnv = staged.agentConfigDirEnv;
142
+ }
122
143
  const launchCommand = buildLaunchCommand({
123
144
  definition,
124
145
  promptFile: stagedPrompt.file,
@@ -126,6 +147,10 @@ export async function resumeWorkspace(config, options) {
126
147
  secretsFile,
127
148
  runner,
128
149
  sandboxName,
150
+ srtPrepareSettingsFile,
151
+ srtAgentSettingsFile,
152
+ srtSettingsDir,
153
+ srtAgentConfigDirEnv,
129
154
  });
130
155
  const launchCmd = stageWorkspaceLaunchCommand(stagedPrompt.directory, launchCommand);
131
156
  try {
@@ -140,6 +165,11 @@ export async function resumeWorkspace(config, options) {
140
165
  }
141
166
  catch (error) {
142
167
  removeStagedPrompt(stagedPrompt.directory);
168
+ // The launch command tears down the settings dir after srt exits; on the
169
+ // pre-launch failure path it never ran, so clean it up here.
170
+ if (srtSettingsDir !== undefined) {
171
+ removeStagedPrompt(srtSettingsDir);
172
+ }
143
173
  throw error;
144
174
  }
145
175
  recordRunState({
@@ -1 +1 @@
1
- {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAoC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAsBzF,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAyDD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CAuIf;AA8ID,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CA4Cf"}
1
+ {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAmBnE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAuBD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA0If;AA8ID,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CA4Cf"}
@@ -1,17 +1,15 @@
1
1
  import { rmSync } from "node:fs";
2
- import path from "node:path";
3
2
  import { loadConfig } from "../lib/config.js";
4
3
  import { openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
5
4
  import { createBoard } from "../lib/board.js";
6
5
  import { buildSources, sourcesFromConfig } from "../lib/buildSources.js";
7
- import { collectAllowedDomains } from "../lib/clearanceHosts.js";
8
- import { buildLaunchCommand, inferAgentCommandName } from "../lib/launchCommand.js";
6
+ import { buildLaunchCommand } from "../lib/launchCommand.js";
9
7
  import { resolvePrepareWorktreeCommand } from "../lib/repositoryHooks.js";
10
8
  import { recordRunState } from "../lib/runState.js";
11
- import { buildSrtSettings } from "../lib/srtPolicy.js";
12
- import { stageBuildSecrets, stagePromptFromTemplate, stageSrtSettings, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
9
+ import { buildAndStageSrtLaunch } from "../lib/srtLaunch.js";
10
+ import { stageBuildSecrets, stagePromptFromTemplate, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
13
11
  import { naturalIdFromCanonical } from "../lib/ticketSource.js";
14
- import { debug, errorMessage, log, okMark, readEnvironmentVariable } from "../lib/util.js";
12
+ import { debug, errorMessage, log, okMark } from "../lib/util.js";
15
13
  import { workspaces } from "../lib/workspaces.js";
16
14
  import { isWorktreeAlreadyExistsError, worktrees } from "../lib/worktrees.js";
17
15
  function stagePrompt(input) {
@@ -28,33 +26,6 @@ function stagePrompt(input) {
28
26
  },
29
27
  });
30
28
  }
31
- /**
32
- * Generate the srt policies for this launch and stage them to temp settings
33
- * files. The agent identity comes from the model `cmd`; the git common dir is
34
- * the parent clone's `.git` (the worktree lives beside it); the egress
35
- * allowlist is translated from the existing clearance env so srt and safehouse
36
- * share one source of truth. Only called when `local.runner` resolves to `srt`.
37
- *
38
- * Two policies: the `prepare` policy is profile-neutral (empty agent → no
39
- * `~/.claude`/`~/.codex` grants) so the repo-controlled prepareWorktree hook
40
- * can't touch the agent's credentials; the `agent` policy carries the agent's
41
- * credential profile.
42
- */
43
- function buildAndStageSrtSettings(input) {
44
- const repoDir = path.resolve(input.config.workspace.projectDir, input.repository);
45
- const base = {
46
- worktreeDir: input.worktreeDir,
47
- gitCommonDir: path.join(repoDir, ".git"),
48
- allowedDomains: collectAllowedDomains({
49
- hosts: readEnvironmentVariable("CLEARANCE_ALLOW_HOSTS"),
50
- files: readEnvironmentVariable("CLEARANCE_ALLOW_HOSTS_FILES"),
51
- }),
52
- };
53
- return stageSrtSettings(input.ticket, {
54
- prepare: buildSrtSettings({ ...base, agent: "" }),
55
- agent: buildSrtSettings({ ...base, agent: inferAgentCommandName(input.definition.cmd) }),
56
- });
57
- }
58
29
  export async function setupWorkspace(config, options, runOptions = {}) {
59
30
  const { ticket, repository, model } = options;
60
31
  const { signal } = runOptions;
@@ -112,8 +83,9 @@ export async function setupWorkspace(config, options, runOptions = {}) {
112
83
  const secretsFile = prepareWorktreeCommand === undefined ? undefined : stageBuildSecrets(promptDir);
113
84
  let srtPrepareSettingsFile;
114
85
  let srtAgentSettingsFile;
86
+ let srtAgentConfigDirEnv;
115
87
  if (runner === "srt") {
116
- const staged = buildAndStageSrtSettings({
88
+ const staged = buildAndStageSrtLaunch({
117
89
  config,
118
90
  repository,
119
91
  ticket,
@@ -123,6 +95,7 @@ export async function setupWorkspace(config, options, runOptions = {}) {
123
95
  srtPrepareSettingsFile = staged.prepareFile;
124
96
  srtAgentSettingsFile = staged.agentFile;
125
97
  srtSettingsDir = staged.directory;
98
+ srtAgentConfigDirEnv = staged.agentConfigDirEnv;
126
99
  }
127
100
  const launchCommand = buildLaunchCommand({
128
101
  definition,
@@ -135,6 +108,7 @@ export async function setupWorkspace(config, options, runOptions = {}) {
135
108
  srtPrepareSettingsFile,
136
109
  srtAgentSettingsFile,
137
110
  srtSettingsDir,
111
+ srtAgentConfigDirEnv,
138
112
  });
139
113
  const launchCmd = stageWorkspaceLaunchCommand(promptDir, launchCommand);
140
114
  debug("Opening workspace...");
@@ -83,6 +83,18 @@ interface LaunchCommandArguments {
83
83
  * `runner === "srt"`; torn down by the launch command after srt exits.
84
84
  */
85
85
  srtSettingsDir?: string | undefined;
86
+ /**
87
+ * Env var that points the agent at its relocated, writable config home
88
+ * (e.g. `{ name: "CODEX_HOME", value: "<settingsDir>/codex-home" }`).
89
+ * Injected into the agent wrap's `env -i` (with an explicit value, not a
90
+ * host-env passthrough) so the agent writes state to the staged dir instead
91
+ * of its read-only real home. Only the agent wrap gets it — the prepare wrap
92
+ * runs the repo hook, not the agent. Undefined for read-only agents (claude).
93
+ */
94
+ srtAgentConfigDirEnv?: {
95
+ name: string;
96
+ value: string;
97
+ } | undefined;
86
98
  }
87
99
  /**
88
100
  * Build the shell command that runs inside the workspace. The prompt is
@@ -1 +1 @@
1
- {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAgB3E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,MAAM,CAMvF;AAqMD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8B9D;AAED,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1C;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA6B7E"}
1
+ {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAgB3E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,MAAM,CAMvF;AAqMD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8B9D;AAED,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1C;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACpE;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA6B7E"}
@@ -473,7 +473,14 @@ function buildSrtLaunchCommand(arguments_) {
473
473
  const baseline = SRT_ENV_BASELINE.map((name) => `${name}="$${name}"`).join(" ");
474
474
  const prepareForward = arguments_.secretsFile === undefined ? "" : srtForwardedEnv(BUILD_SECRET_NAMES);
475
475
  const prepareWrap = `env -i ${baseline}${prepareForward} ${prepareTarget}`;
476
- const agentWrap = `env -i ${baseline}${srtForwardedEnv(arguments_.definition.preLaunchEnv ?? [])} ${agentTarget}`;
476
+ // The relocated config-home env (e.g. CODEX_HOME) is an explicit value, not a
477
+ // `VAR="$VAR"` host passthrough — groundcrew computed the staged path, it is
478
+ // not in the launch shell's env. The name is a fixed identifier; the value is
479
+ // single-quoted. Only the agent wrap gets it.
480
+ const agentConfigDirAssignment = arguments_.srtAgentConfigDirEnv === undefined
481
+ ? ""
482
+ : ` ${arguments_.srtAgentConfigDirEnv.name}=${shellSingleQuote(arguments_.srtAgentConfigDirEnv.value)}`;
483
+ const agentWrap = `env -i ${baseline}${agentConfigDirAssignment}${srtForwardedEnv(arguments_.definition.preLaunchEnv ?? [])} ${agentTarget}`;
477
484
  // One EXIT trap wipes both the settings dir and the prompt dir, covering
478
485
  // every failure window between here and the post-wrap cleanup.
479
486
  const cleanup = `rm -rf ${shellSingleQuote(arguments_.srtSettingsDir)}; rm -rf ${shellSingleQuote(promptDir)}`;
@@ -0,0 +1,47 @@
1
+ import type { ModelDefinition, ResolvedConfig } from "./config.ts";
2
+ export interface StagedSrtLaunch {
3
+ /** Dedicated temp dir holding the settings files (and any relocated config home). */
4
+ directory: string;
5
+ /** Profile-neutral policy for the prepareWorktree wrap (no agent credentials). */
6
+ prepareFile: string;
7
+ /** Full agent policy for the agent wrap. */
8
+ agentFile: string;
9
+ /**
10
+ * Env var pointing the agent at its relocated, writable config home (codex's
11
+ * `CODEX_HOME`). Threaded into the agent wrap by `buildLaunchCommand`.
12
+ * Undefined for read-only agents (claude), which run with a read-only home.
13
+ */
14
+ agentConfigDirEnv?: {
15
+ name: string;
16
+ value: string;
17
+ };
18
+ }
19
+ /**
20
+ * Generate the srt policies for a launch and stage them, plus — for agents that
21
+ * cannot run with a read-only config home (codex) — a relocated, writable
22
+ * config dir seeded with the minimal files the agent needs to authenticate and
23
+ * keep its config. Shared by `setupWorkspace` (fresh runs) and `resumeWorkspace`
24
+ * (resumes) so both behave identically under the srt runner.
25
+ *
26
+ * Two policies, distinct surfaces:
27
+ * - the `prepare` policy is profile-neutral (empty agent → no `~/.claude` /
28
+ * `~/.codex` grants, no relocated home) so the repo-controlled prepareWorktree
29
+ * hook can't touch the agent's credentials;
30
+ * - the `agent` policy carries the agent's read-only config profile and, when
31
+ * the agent relocates, the writable relocated home.
32
+ *
33
+ * The relocated home lives **inside** the settings dir but is the only thing
34
+ * under it granted to the sandbox — the settings JSON siblings are never
35
+ * read-granted, so the agent can't read or rewrite its own policy. The launch
36
+ * command tears the whole dir down after srt exits.
37
+ */
38
+ export declare function buildAndStageSrtLaunch(input: {
39
+ config: ResolvedConfig;
40
+ repository: string;
41
+ ticket: string;
42
+ worktreeDir: string;
43
+ definition: ModelDefinition;
44
+ /** Defaults to `os.homedir()`. Injected in tests to seed from a fixture home. */
45
+ homeDir?: string;
46
+ }): StagedSrtLaunch;
47
+ //# sourceMappingURL=srtLaunch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"srtLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/srtLaunch.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAKnE,MAAM,WAAW,eAAe;IAC9B,qFAAqF;IACrF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,MAAM,EAAE,cAAc,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,eAAe,CAAC;IAC5B,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,eAAe,CA8ClB"}
@@ -0,0 +1,84 @@
1
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { collectAllowedDomains } from "./clearanceHosts.js";
5
+ import { inferAgentCommandName } from "./launchCommand.js";
6
+ import { agentConfigRelocation, buildSrtSettings } from "./srtPolicy.js";
7
+ import { readEnvironmentVariable } from "./util.js";
8
+ /**
9
+ * Generate the srt policies for a launch and stage them, plus — for agents that
10
+ * cannot run with a read-only config home (codex) — a relocated, writable
11
+ * config dir seeded with the minimal files the agent needs to authenticate and
12
+ * keep its config. Shared by `setupWorkspace` (fresh runs) and `resumeWorkspace`
13
+ * (resumes) so both behave identically under the srt runner.
14
+ *
15
+ * Two policies, distinct surfaces:
16
+ * - the `prepare` policy is profile-neutral (empty agent → no `~/.claude` /
17
+ * `~/.codex` grants, no relocated home) so the repo-controlled prepareWorktree
18
+ * hook can't touch the agent's credentials;
19
+ * - the `agent` policy carries the agent's read-only config profile and, when
20
+ * the agent relocates, the writable relocated home.
21
+ *
22
+ * The relocated home lives **inside** the settings dir but is the only thing
23
+ * under it granted to the sandbox — the settings JSON siblings are never
24
+ * read-granted, so the agent can't read or rewrite its own policy. The launch
25
+ * command tears the whole dir down after srt exits.
26
+ */
27
+ export function buildAndStageSrtLaunch(input) {
28
+ const agent = inferAgentCommandName(input.definition.cmd);
29
+ const homeDir = input.homeDir ?? os.homedir();
30
+ const repoDir = path.resolve(input.config.workspace.projectDir, input.repository);
31
+ const base = {
32
+ worktreeDir: input.worktreeDir,
33
+ gitCommonDir: path.join(repoDir, ".git"),
34
+ allowedDomains: collectAllowedDomains({
35
+ hosts: readEnvironmentVariable("CLEARANCE_ALLOW_HOSTS"),
36
+ files: readEnvironmentVariable("CLEARANCE_ALLOW_HOSTS_FILES"),
37
+ }),
38
+ };
39
+ const directory = mkdtempSync(path.join(os.tmpdir(), `groundcrew-srt-${input.ticket}-`));
40
+ const relocation = agentConfigRelocation(agent);
41
+ let relocatedConfigDir;
42
+ let agentConfigDirEnv;
43
+ if (relocation !== undefined) {
44
+ relocatedConfigDir = path.join(directory, `${agent}-home`);
45
+ mkdirSync(relocatedConfigDir, { recursive: true });
46
+ seedRelocatedConfigDir({
47
+ sourceDir: path.join(homeDir, relocation.sourceHomeRelativeDir),
48
+ seedFiles: relocation.seedFiles,
49
+ relocatedConfigDir,
50
+ });
51
+ agentConfigDirEnv = { name: relocation.configDirEnv, value: relocatedConfigDir };
52
+ }
53
+ const prepare = buildSrtSettings({ ...base, agent: "" });
54
+ const agentSettings = buildSrtSettings({
55
+ ...base,
56
+ agent,
57
+ ...(relocatedConfigDir === undefined ? {} : { relocatedConfigDir }),
58
+ });
59
+ const prepareFile = path.join(directory, "prepare-settings.json");
60
+ const agentFile = path.join(directory, "agent-settings.json");
61
+ writeFileSync(prepareFile, `${JSON.stringify(prepare, undefined, 2)}\n`);
62
+ writeFileSync(agentFile, `${JSON.stringify(agentSettings, undefined, 2)}\n`);
63
+ return {
64
+ directory,
65
+ prepareFile,
66
+ agentFile,
67
+ ...(agentConfigDirEnv === undefined ? {} : { agentConfigDirEnv }),
68
+ };
69
+ }
70
+ /**
71
+ * Copy the agent's minimal credential/config files into the relocated home.
72
+ * Best-effort per file: a missing source (e.g. the user isn't logged into the
73
+ * agent, or has no config) is skipped rather than aborting the launch — the
74
+ * agent then reports its own "not logged in" state, which is the correct signal.
75
+ */
76
+ function seedRelocatedConfigDir(input) {
77
+ for (const file of input.seedFiles) {
78
+ const source = path.join(input.sourceDir, file);
79
+ if (!existsSync(source)) {
80
+ continue;
81
+ }
82
+ copyFileSync(source, path.join(input.relocatedConfigDir, file));
83
+ }
84
+ }
@@ -10,14 +10,30 @@
10
10
  * region (`/Users` on macOS, `/home` + `/root` on Linux) so the agent cannot
11
11
  * read `~/.ssh`, `~/.aws`, shell history, or unrelated repos; `allowRead`
12
12
  * then re-opens exactly the worktree, the repo's git metadata, the language
13
- * toolchains needed to *run* the agent, and the agent's own credential dirs.
14
- * srt skips non-existent allow/deny paths, so listing a toolchain that isn't
15
- * installed is harmless.
16
- * - **Writes** are allow-only in srt, so a narrow `allowWrite` (worktree, git
17
- * metadata, npm cache, the agent's own state) is the primary defense against
18
- * the toolchain-persistence vector (agent-safehouse#102). `denyWrite` is
19
- * belt-and-suspenders over global toolchain bins; it uses **literal paths
20
- * only** because bubblewrap silently ignores globs on Linux.
13
+ * toolchains needed to *run* the agent, the agent's own config/credential
14
+ * dirs, and on macOS, for keychain-authenticated agents (claude) the user
15
+ * keychain dir (`~/Library/Keychains`) so the agent can authenticate under
16
+ * the home mask. srt skips non-existent allow/deny paths, so listing a path
17
+ * that isn't present (a Linux keychain dir, an uninstalled toolchain) is
18
+ * harmless.
19
+ * - **Writes** are allow-only in srt. Two STAFF-1305 structural fixes shape it:
20
+ * 1. **Agent state (work item 1).** The host-CLI persistence vector (planting
21
+ * hooks, `mcpServers`, commands, plugins, … that execute on the user's
22
+ * next host run) is closed per agent. **claude** runs with a writable
23
+ * `~/.claude` (its Bash tool creates `session-env`/scratch state there) but
24
+ * every fixed-path executable/instruction surface — including
25
+ * `~/.claude.json` (`mcpServers`) and the bundled `chrome` binary — is
26
+ * denied; claude tolerates those write denials. **codex** hard-fails with a
27
+ * read-only home, so it is pointed at a relocated, per-launch writable
28
+ * config dir (`CODEX_HOME`, see {@link agentConfigRelocation}) and its real
29
+ * `~/.codex` is never write-granted at all.
30
+ * 2. **Git (work item 2).** The git common dir is granted write as a **narrow
31
+ * allowlist** of exactly the paths `status/diff/add/commit/push/gc` write —
32
+ * never wholesale — so the per-worktree gitdir redirection files, sibling
33
+ * worktree gitdirs, and the repo `config`/`hooks` stay unwritable.
34
+ * `denyWrite` is belt-and-suspenders over global toolchain bins and the agent
35
+ * homes; it uses **literal paths only** because bubblewrap silently ignores
36
+ * globs on Linux.
21
37
  * - **Network** is allow-only and sourced from the existing clearance
22
38
  * allowlist (see {@link ./clearanceHosts.ts}); local binding and unix sockets
23
39
  * stay off (the docker socket and the DNS-exfil vector in srt#88).
@@ -32,14 +48,22 @@ export interface BuildSrtSettingsInput {
32
48
  worktreeDir: string;
33
49
  /**
34
50
  * Absolute path to the repo's git common dir (the parent clone's `.git`).
35
- * The worktree's per-worktree git dir lives under it, so granting it
36
- * read + write covers `git status/diff/add/commit/branch`.
51
+ * Granted read wholesale, but write only as a narrow allowlist of the paths
52
+ * the git workflow actually touches (see `GIT_COMMON_WRITE_PATHS`).
37
53
  */
38
54
  gitCommonDir: string;
39
55
  /** Agent identity (e.g. "claude", "codex") used to pick the credential profile. */
40
56
  agent: string;
41
57
  /** srt `network.allowedDomains`, already translated from the clearance allowlist. */
42
58
  allowedDomains: readonly string[];
59
+ /**
60
+ * Absolute path to the agent's relocated, writable config/state home for this
61
+ * launch (codex's `CODEX_HOME`). When set it is granted read + write so the
62
+ * agent persists session state there instead of its real home, which stays
63
+ * read-only. claude does not relocate (its macOS keychain credential is bound
64
+ * to the default config dir), so this is omitted for it.
65
+ */
66
+ relocatedConfigDir?: string;
43
67
  /** Defaults to `process.platform`. Injected in tests to exercise both deny-read roots. */
44
68
  platform?: NodeJS.Platform;
45
69
  /** Defaults to `os.homedir()`. Injected in tests. */
@@ -47,5 +71,35 @@ export interface BuildSrtSettingsInput {
47
71
  /** Defaults to `process.execPath`; used to locate the global node_modules to deny writes to. */
48
72
  nodeExecPath?: string;
49
73
  }
74
+ /**
75
+ * How an agent that cannot run with a read-only config home is pointed at a
76
+ * relocated, per-launch writable home instead. The launch stages a temp dir,
77
+ * seeds it with the minimal files the agent needs to authenticate + keep its
78
+ * config, exports `configDirEnv` to that dir, and grants it write — so the real
79
+ * home (which holds the persistence surfaces) is never written.
80
+ *
81
+ * Empirically (STAFF-1305 live validation, macOS): codex hard-fails to launch
82
+ * with a read-only `~/.codex` ("failed to initialize in-process app-server
83
+ * client") and authenticates from a file (`auth.json`), so relocating
84
+ * `CODEX_HOME` + seeding `auth.json`/`config.toml` both unblocks it and closes
85
+ * persistence. claude is deliberately absent: its macOS keychain credential is
86
+ * bound to the default config dir, so relocating `CLAUDE_CONFIG_DIR` breaks
87
+ * auth — claude instead runs with a writable home whose executable surfaces are
88
+ * denied (see `AGENT_SRT_PROFILES`).
89
+ */
90
+ export interface AgentConfigRelocation {
91
+ /** Env var that points the agent at a relocated config home. */
92
+ configDirEnv: string;
93
+ /** Home-relative dir the seed files are copied from (the agent's real home). */
94
+ sourceHomeRelativeDir: string;
95
+ /** Files (relative to `sourceHomeRelativeDir`) seeded into the relocated home. */
96
+ seedFiles: readonly string[];
97
+ }
98
+ /**
99
+ * Return the config-relocation spec for an agent, or `undefined` when the agent
100
+ * runs with a writable real home + deny-list and does not relocate (claude,
101
+ * unknown agents).
102
+ */
103
+ export declare function agentConfigRelocation(agent: string): AgentConfigRelocation | undefined;
50
104
  export declare function buildSrtSettings(input: BuildSrtSettingsInput): SandboxRuntimeConfig;
51
105
  //# sourceMappingURL=srtPolicy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"srtPolicy.d.ts","sourceRoot":"","sources":["../../src/lib/srtPolicy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,+BAA+B,CAAC;AAEvC,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC;IACd,qFAAqF;IACrF,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gGAAgG;IAChG,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA2ID,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,oBAAoB,CA6GnF"}
1
+ {"version":3,"file":"srtPolicy.d.ts","sourceRoot":"","sources":["../../src/lib/srtPolicy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAMH,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,+BAA+B,CAAC;AAEvC,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC;IACd,qFAAqF;IACrF,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gGAAgG;IAChG,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,qBAAqB,EAAE,MAAM,CAAC;IAC9B,kFAAkF;IAClF,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,qBAAqB,GAAG,SAAS,CAEtF;AAsMD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,oBAAoB,CAuHnF"}
@@ -10,14 +10,30 @@
10
10
  * region (`/Users` on macOS, `/home` + `/root` on Linux) so the agent cannot
11
11
  * read `~/.ssh`, `~/.aws`, shell history, or unrelated repos; `allowRead`
12
12
  * then re-opens exactly the worktree, the repo's git metadata, the language
13
- * toolchains needed to *run* the agent, and the agent's own credential dirs.
14
- * srt skips non-existent allow/deny paths, so listing a toolchain that isn't
15
- * installed is harmless.
16
- * - **Writes** are allow-only in srt, so a narrow `allowWrite` (worktree, git
17
- * metadata, npm cache, the agent's own state) is the primary defense against
18
- * the toolchain-persistence vector (agent-safehouse#102). `denyWrite` is
19
- * belt-and-suspenders over global toolchain bins; it uses **literal paths
20
- * only** because bubblewrap silently ignores globs on Linux.
13
+ * toolchains needed to *run* the agent, the agent's own config/credential
14
+ * dirs, and on macOS, for keychain-authenticated agents (claude) the user
15
+ * keychain dir (`~/Library/Keychains`) so the agent can authenticate under
16
+ * the home mask. srt skips non-existent allow/deny paths, so listing a path
17
+ * that isn't present (a Linux keychain dir, an uninstalled toolchain) is
18
+ * harmless.
19
+ * - **Writes** are allow-only in srt. Two STAFF-1305 structural fixes shape it:
20
+ * 1. **Agent state (work item 1).** The host-CLI persistence vector (planting
21
+ * hooks, `mcpServers`, commands, plugins, … that execute on the user's
22
+ * next host run) is closed per agent. **claude** runs with a writable
23
+ * `~/.claude` (its Bash tool creates `session-env`/scratch state there) but
24
+ * every fixed-path executable/instruction surface — including
25
+ * `~/.claude.json` (`mcpServers`) and the bundled `chrome` binary — is
26
+ * denied; claude tolerates those write denials. **codex** hard-fails with a
27
+ * read-only home, so it is pointed at a relocated, per-launch writable
28
+ * config dir (`CODEX_HOME`, see {@link agentConfigRelocation}) and its real
29
+ * `~/.codex` is never write-granted at all.
30
+ * 2. **Git (work item 2).** The git common dir is granted write as a **narrow
31
+ * allowlist** of exactly the paths `status/diff/add/commit/push/gc` write —
32
+ * never wholesale — so the per-worktree gitdir redirection files, sibling
33
+ * worktree gitdirs, and the repo `config`/`hooks` stay unwritable.
34
+ * `denyWrite` is belt-and-suspenders over global toolchain bins and the agent
35
+ * homes; it uses **literal paths only** because bubblewrap silently ignores
36
+ * globs on Linux.
21
37
  * - **Network** is allow-only and sourced from the existing clearance
22
38
  * allowlist (see {@link ./clearanceHosts.ts}); local binding and unix sockets
23
39
  * stay off (the docker socket and the DNS-exfil vector in srt#88).
@@ -31,25 +47,33 @@ import path from "node:path";
31
47
  import process from "node:process";
32
48
  import { SandboxRuntimeConfigSchema, } from "@anthropic-ai/sandbox-runtime";
33
49
  /**
34
- * Per-agent credential/config paths re-opened under the home deny-read mask.
35
- * Deliberately narrow no blanket `~/.config`, which would re-expose
36
- * unrelated apps' secrets. Extend via config in a later phase; an unknown
37
- * agent gets no extra home access and must be granted paths explicitly.
50
+ * Return the config-relocation spec for an agent, or `undefined` when the agent
51
+ * runs with a writable real home + deny-list and does not relocate (claude,
52
+ * unknown agents).
53
+ */
54
+ export function agentConfigRelocation(agent) {
55
+ return AGENT_CONFIG_RELOCATIONS[agent.toLowerCase()];
56
+ }
57
+ /**
58
+ * Per-agent credential/config profiles. Deliberately narrow — no blanket
59
+ * `~/.config`, which would re-expose unrelated apps' secrets. An unknown agent
60
+ * gets no extra home access and must be granted paths explicitly.
38
61
  *
39
- * `writePaths` keep the agent's mutable state writable (sessions, history,
40
- * todos, projects, caches, sqlite a large, version-volatile set that an
41
- * allowlist would break on the next agent release); `denyPaths` re-close the
42
- * small, enumerable executable/instruction surfaces within them. The agent
43
- * does not write those during a task, so denying them degrades gracefully. The
44
- * lists below were validated against the real `~/.claude` / `~/.codex` layout;
45
- * srt's own mandatory deny additionally covers `.claude/commands`/`agents`.
46
- * Drift across agent versions is tracked for the live-validation pass.
62
+ * claude keeps a writable home (`writePaths`) with the executable surfaces
63
+ * re-closed (`denyPaths`) because its macOS keychain credential is bound to the
64
+ * default config dir, so it cannot be relocated. codex has no `writePaths`: it
65
+ * relocates (see `AGENT_CONFIG_RELOCATIONS`), so its real `~/.codex` is
66
+ * read-only and no per-surface deny-list is needed there.
47
67
  */
48
68
  const AGENT_SRT_PROFILES = {
49
69
  claude: {
50
70
  readPaths: [".claude", ".claude.json"],
51
- writePaths: [".claude", ".claude.json"],
71
+ writePaths: [".claude"],
52
72
  denyPaths: [
73
+ // The mcpServers config — claude spawns these commands on every startup,
74
+ // the sharpest host-RCE persistence vector. claude tolerates this being
75
+ // read-only (validated live; it does not write it during a task).
76
+ ".claude.json",
53
77
  ".claude/settings.json",
54
78
  ".claude/settings.local.json",
55
79
  ".claude/commands",
@@ -59,27 +83,35 @@ const AGENT_SRT_PROFILES = {
59
83
  ".claude/hooks",
60
84
  ".claude/statusline.sh",
61
85
  ".claude/CLAUDE.md",
86
+ // Bundled browser binary — replaceable with a malicious one that runs when
87
+ // claude next drives a browser on the host.
88
+ ".claude/chrome",
62
89
  // ~/.claude is itself a git repo; deny the executable surfaces within its
63
90
  // gitdir (commits, if any, still write objects/refs).
64
91
  ".claude/.git/hooks",
65
92
  ".claude/.git/config",
66
93
  ],
94
+ usesMacosKeychain: true,
67
95
  },
68
96
  codex: {
97
+ // Read-only: codex relocates its writable home (CODEX_HOME), so the real
98
+ // ~/.codex never needs write and no per-surface deny-list is required.
69
99
  readPaths: [".codex"],
70
- writePaths: [".codex"],
71
- denyPaths: [
72
- ".codex/config.toml",
73
- ".codex/hooks.json",
74
- ".codex/AGENTS.md",
75
- ".codex/plugins",
76
- ".codex/skills",
77
- ".codex/rules",
78
- ".codex/.git/hooks",
79
- ".codex/.git/config",
80
- ],
100
+ writePaths: [],
101
+ denyPaths: [],
102
+ },
103
+ };
104
+ const AGENT_CONFIG_RELOCATIONS = {
105
+ codex: {
106
+ configDirEnv: "CODEX_HOME",
107
+ sourceHomeRelativeDir: ".codex",
108
+ // auth.json carries the ChatGPT OAuth tokens (codex reads creds from a file,
109
+ // not the keychain); config.toml preserves the user's codex configuration.
110
+ seedFiles: ["auth.json", "config.toml"],
81
111
  },
82
112
  };
113
+ /** macOS user keychain dir, re-opened read-only for keychain-authenticated agents. */
114
+ const MACOS_KEYCHAIN_READ_PATH = "Library/Keychains";
83
115
  /**
84
116
  * Language toolchains and version managers re-opened read-only so the agent's
85
117
  * runtime (and any installed CLIs) can execute even though they live under the
@@ -113,14 +145,49 @@ const TOOLCHAIN_READ_ROOTS = [
113
145
  "go/bin",
114
146
  "go/pkg",
115
147
  ];
148
+ /**
149
+ * The git common dir is granted write only at these relative subpaths — never
150
+ * wholesale — so the agent's `status/diff/add/commit/push` + `gc`/`pack-refs`
151
+ * work while the persistence/tamper surfaces stay unwritable by *omission*
152
+ * (macOS Seatbelt is deny-beats-allow, so a denied parent cannot be re-allowed
153
+ * for a child — the allowlist is the only correct shape). Closed by not being
154
+ * listed: `config`, `hooks`, `modules`, and **sibling** worktree gitdirs under
155
+ * `worktrees/<other>` (cross-ticket tamper). This worktree's own gitdir is
156
+ * granted separately and its redirection files carved back out (see
157
+ * `gitCommonWriteDenies`).
158
+ *
159
+ * Enumerated against a live run under srt (STAFF-1305): `objects` (loose +
160
+ * packs + commit-graph), `refs` + `logs` (branch + remote-tracking refs and
161
+ * their reflogs — granted whole because `gc` packs/deletes refs across *all*
162
+ * branches, so scoping to the current branch would break `gc`), `packed-refs`
163
+ * (+ `.lock`/`.new` temps), `info` (`update-server-info`), and the root-level
164
+ * `gc.pid`/`HEAD`/`ORIG_HEAD`/`FETCH_HEAD` (+ `.lock` temps) that `gc`'s
165
+ * repo-global reflog expiry touches. None are code-execution surfaces.
166
+ */
167
+ const GIT_COMMON_WRITE_PATHS = [
168
+ "objects",
169
+ "refs",
170
+ "logs",
171
+ "info",
172
+ "packed-refs",
173
+ "packed-refs.lock",
174
+ "packed-refs.new",
175
+ "gc.pid",
176
+ "gc.pid.lock",
177
+ "HEAD",
178
+ "HEAD.lock",
179
+ "ORIG_HEAD",
180
+ "FETCH_HEAD",
181
+ ];
116
182
  /**
117
183
  * Every agent credential/state home dir. A profile that does NOT own one of
118
- * these must deny writes to it — both as cross-agent defense (the codex profile
119
- * shouldn't write `~/.claude`) and to override srt's hardcoded default write
120
- * path `~/.claude/debug`, which `getDefaultWritePaths()` adds to every policy.
121
- * Without this, that default re-opens `~/.claude/debug` (and, on Linux, makes
122
- * it readable via the write bind) even under the profile-neutral prepare
123
- * policy. `denyWrite` wins over `allowWrite`, so denying the home dir overrides
184
+ * these (it isn't in the profile's `writePaths`) must deny writes to it — both
185
+ * as cross-agent defense (the codex profile shouldn't write `~/.claude`) and to
186
+ * override srt's hardcoded default write path `~/.claude/debug`, which
187
+ * `getDefaultWritePaths()` adds to every policy. Without this, that default
188
+ * re-opens `~/.claude/debug` (and, on Linux, makes it readable via the write
189
+ * bind) even under the profile-neutral prepare policy and the relocating codex
190
+ * profile. `denyWrite` wins over `allowWrite`, so denying the home dir overrides
124
191
  * the default.
125
192
  */
126
193
  const ALL_AGENT_HOME_DIRS = [".claude", ".codex"];
@@ -156,6 +223,8 @@ export function buildSrtSettings(input) {
156
223
  denyPaths: [],
157
224
  };
158
225
  const underHome = (relativePath) => path.join(homeDir, relativePath);
226
+ const underGit = (relativePath) => path.join(input.gitCommonDir, relativePath);
227
+ const worktreeBasename = path.basename(input.worktreeDir);
159
228
  // `<nodeBin>/../` is the node prefix; nvm/Volta-managed nodes keep their
160
229
  // global modules at `<prefix>/lib/node_modules` and shims at `<prefix>/bin`.
161
230
  const nodePrefix = path.dirname(path.dirname(nodeExecPath));
@@ -167,6 +236,12 @@ export function buildSrtSettings(input) {
167
236
  // Linux (the worktree, if it lives under /mnt, is re-allowed below since
168
237
  // allowRead wins over denyRead).
169
238
  const denyRead = platform === "darwin" ? ["/Users"] : ["/home", "/root", "/mnt"];
239
+ // macOS keychain re-open for keychain-authenticated agents. Home-relative so
240
+ // it is a no-op on Linux (the path does not exist there; srt skips it), where
241
+ // these agents read credentials from a file under their (readable) home.
242
+ const keychainRead = platform === "darwin" && profile.usesMacosKeychain === true
243
+ ? [underHome(MACOS_KEYCHAIN_READ_PATH)]
244
+ : [];
170
245
  const allowRead = unique([
171
246
  input.worktreeDir,
172
247
  input.gitCommonDir,
@@ -174,43 +249,43 @@ export function buildSrtSettings(input) {
174
249
  ...TOOLCHAIN_READ_ROOTS.map(underHome),
175
250
  ...GIT_READ_PATHS.map(underHome),
176
251
  ...profile.readPaths.map(underHome),
252
+ ...keychainRead,
253
+ ...(input.relocatedConfigDir === undefined ? [] : [input.relocatedConfigDir]),
177
254
  ]);
178
255
  const allowWrite = unique([
179
256
  input.worktreeDir,
180
- input.gitCommonDir,
181
257
  underHome(".npm"),
258
+ // Narrow git allowlist — never the whole common dir (see GIT_COMMON_WRITE_PATHS).
259
+ ...GIT_COMMON_WRITE_PATHS.map(underGit),
260
+ underGit(path.join("worktrees", worktreeBasename)),
182
261
  ...profile.writePaths.map(underHome),
262
+ // The agent's relocated, writable config home (codex). Absent for agents
263
+ // that write their real home behind a deny-list (claude).
264
+ ...(input.relocatedConfigDir === undefined ? [] : [input.relocatedConfigDir]),
183
265
  ]);
184
266
  const denyWrite = unique([
185
267
  nodeGlobalModules,
186
268
  nodeBinDir,
187
269
  ...TOOLCHAIN_WRITE_DENY.map(underHome),
188
270
  // Carve the agent's executable/config surfaces back out of its writable
189
- // state dir so a prompted agent can't plant a hook/command/plugin that runs
190
- // on the user's next host invocation (denyWrite wins over allowWrite).
271
+ // state dir so a prompted agent can't plant a hook/command/plugin/mcpServer
272
+ // that runs on the user's next host invocation (denyWrite wins over
273
+ // allowWrite).
191
274
  ...profile.denyPaths.map(underHome),
192
275
  // Deny agent home dirs this profile does not own — counters srt's default
193
- // `~/.claude/debug` write path for the neutral prepare policy and the codex
194
- // profile, and keeps profiles from writing each other's credentials.
276
+ // `~/.claude/debug` write path for the neutral prepare policy and the
277
+ // relocating codex profile, and keeps profiles from writing each other's
278
+ // credentials.
195
279
  ...ALL_AGENT_HOME_DIRS.filter((dir) => !profile.writePaths.includes(dir)).map(underHome),
196
- // Narrow the broad gitCommonDir write grant: the agent commits objects and
197
- // refs but must never rewrite the repo's config or install hooks (a
198
- // persistence vector). srt's mandatory deny is anchored at the cwd `.git`,
199
- // which for a worktree is a file pointing elsewhere so guard the common
200
- // dir's config/hooks explicitly.
201
- path.join(input.gitCommonDir, "config"),
202
- path.join(input.gitCommonDir, "hooks"),
203
- // Nested git persistence surfaces the top-level config/hooks deny misses:
204
- // submodule gitdirs (`.git/modules/**/{config,hooks}`) and this worktree's
205
- // per-worktree `config.worktree`. Commits never write these. (Cross-worktree
206
- // isolation + full git-write scoping are tracked for live validation —
207
- // STAFF-1305.)
208
- path.join(input.gitCommonDir, "modules"),
209
- path.join(input.gitCommonDir, "worktrees", path.basename(input.worktreeDir), "config.worktree"),
210
- // The worktree's `.git` is a pointer *file* (`gitdir: …`). Deny writing it
211
- // so the agent can't redirect the gitdir to a writable fake with its own
212
- // config/hooks (e.g. `core.fsmonitor`) that runs when git next operates in
213
- // this worktree on the host. git sets this pointer once at creation.
280
+ // Carve the per-worktree git redirection + config files back out of the
281
+ // granted worktree gitdir: `commondir`/`gitdir` redirect git to a fake
282
+ // common dir with its own hooks/config, and `config.worktree` can set
283
+ // `core.*` hooks that run when git next operates here on the host. git
284
+ // writes these once at worktree creation, never during a task.
285
+ ...gitCommonWriteDenies(input.gitCommonDir, worktreeBasename),
286
+ // The worktree's `.git` is a pointer *file* (`gitdir: …`). Deny writing it so
287
+ // the agent can't redirect the gitdir to a writable fake with its own
288
+ // config/hooks. git sets this pointer once at creation.
214
289
  path.join(input.worktreeDir, ".git"),
215
290
  ]);
216
291
  const settings = {
@@ -246,6 +321,20 @@ export function buildSrtSettings(input) {
246
321
  }
247
322
  return settings;
248
323
  }
324
+ /**
325
+ * Files inside this worktree's granted gitdir that must stay unwritable: the
326
+ * `commondir`/`gitdir` redirection pointers and the per-worktree
327
+ * `config.worktree`. Returned as denies so they override the gitdir's
328
+ * `allowWrite` grant.
329
+ */
330
+ function gitCommonWriteDenies(gitCommonDir, worktreeBasename) {
331
+ const worktreeGitDir = path.join(gitCommonDir, "worktrees", worktreeBasename);
332
+ return [
333
+ path.join(worktreeGitDir, "commondir"),
334
+ path.join(worktreeGitDir, "gitdir"),
335
+ path.join(worktreeGitDir, "config.worktree"),
336
+ ];
337
+ }
249
338
  function unique(values) {
250
339
  return [...new Set(values)];
251
340
  }
@@ -1,4 +1,3 @@
1
- import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
2
1
  import { type ResolvedConfig } from "./config.ts";
3
2
  export interface StagedPrompt {
4
3
  directory: string;
@@ -28,27 +27,6 @@ export declare function stagePromptFromTemplate(input: {
28
27
  * has nothing to forward, leaving the launch command unchanged.
29
28
  */
30
29
  export declare function stageBuildSecrets(promptDir: string): string | undefined;
31
- export interface StagedSrtSettings {
32
- directory: string;
33
- /** Profile-neutral policy for the prepareWorktree wrap (no agent credentials). */
34
- prepareFile: string;
35
- /** Full agent policy for the agent wrap. */
36
- agentFile: string;
37
- }
38
- /**
39
- * Stage the generated srt settings JSON in its own temp dir, separate from the
40
- * prompt dir (which the launch command wipes before the agent execs). The
41
- * launch command tears this dir down after srt exits.
42
- *
43
- * Two files: the repo-controlled prepareWorktree hook runs under the
44
- * profile-neutral `prepare` policy (no `~/.claude`/`~/.codex` grants), while
45
- * the agent runs under the full `agent` policy — so a malicious repo hook
46
- * cannot read or mutate the agent's credentials before the agent starts.
47
- */
48
- export declare function stageSrtSettings(ticket: string, settings: {
49
- prepare: SandboxRuntimeConfig;
50
- agent: SandboxRuntimeConfig;
51
- }): StagedSrtSettings;
52
30
  export declare function stageWorkspaceLaunchCommand(promptDir: string, command: string): string;
53
31
  export declare function removeStagedPrompt(directory: string): void;
54
32
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"stagedLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/stagedLaunch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AAK1E,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAItE,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,uBAAuB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gCAAgC,EAAE,MAAM,CAAC;CAC1C;AAWD,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,YAAY,CAKf;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,uBAAuB,CAAC;CACpC,GAAG,YAAY,CAMf;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAevE;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE;IAAE,OAAO,EAAE,oBAAoB,CAAC;IAAC,KAAK,EAAE,oBAAoB,CAAA;CAAE,GACvE,iBAAiB,CAOnB;AAQD,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAE1D"}
1
+ {"version":3,"file":"stagedLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/stagedLaunch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAItE,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,uBAAuB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gCAAgC,EAAE,MAAM,CAAC;CAC1C;AAWD,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,YAAY,CAKf;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,uBAAuB,CAAC;CACpC,GAAG,YAAY,CAMf;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAevE;AAQD,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAE1D"}
@@ -46,24 +46,6 @@ export function stageBuildSecrets(promptDir) {
46
46
  writeFileSync(secretsFile, `${lines.join("\n")}\n`, { mode: 0o600 });
47
47
  return secretsFile;
48
48
  }
49
- /**
50
- * Stage the generated srt settings JSON in its own temp dir, separate from the
51
- * prompt dir (which the launch command wipes before the agent execs). The
52
- * launch command tears this dir down after srt exits.
53
- *
54
- * Two files: the repo-controlled prepareWorktree hook runs under the
55
- * profile-neutral `prepare` policy (no `~/.claude`/`~/.codex` grants), while
56
- * the agent runs under the full `agent` policy — so a malicious repo hook
57
- * cannot read or mutate the agent's credentials before the agent starts.
58
- */
59
- export function stageSrtSettings(ticket, settings) {
60
- const directory = mkdtempSync(path.join(tmpdir(), `groundcrew-srt-${ticket}-`));
61
- const prepareFile = path.join(directory, "prepare-settings.json");
62
- const agentFile = path.join(directory, "agent-settings.json");
63
- writeFileSync(prepareFile, `${JSON.stringify(settings.prepare, undefined, 2)}\n`);
64
- writeFileSync(agentFile, `${JSON.stringify(settings.agent, undefined, 2)}\n`);
65
- return { directory, prepareFile, agentFile };
66
- }
67
49
  function stageLaunchScript(promptDir, command) {
68
50
  const launcherFile = path.join(promptDir, "launch.sh");
69
51
  writeFileSync(launcherFile, `#!/usr/bin/env bash\n${command}\n`, { mode: 0o700 });
package/docs/runners.md CHANGED
@@ -45,8 +45,8 @@ crew run --watch # with local.runner: "srt" in crew.config.ts
45
45
 
46
46
  Groundcrew generates a per-launch policy itself (Safehouse's `.sb` profiles have no equivalent here):
47
47
 
48
- - **Reads**: the home region (`/Users` on macOS, `/home`+`/root`+`/mnt` on Linux — `/mnt` covers WSL's Windows drive mounts) is denied, then the worktree, the repo's git metadata, the language toolchains needed to run the agent, and the agent's own credential dirs (`~/.claude`, `~/.codex`, …) are re-opened. The agent cannot read `~/.ssh`, `~/.aws`, shell history, or unrelated repos.
49
- - **Writes**: allow-only the worktree, git metadata, the npm cache, and the agent's state dir. Global toolchain bins (`~/.cargo/bin`, global `node_modules`, the npx cache, …) are never writable, and the agent's own executable/config surfaces (`~/.claude/settings.json` and its hooks, `commands/`, `agents/`, `plugins/`, `skills/`; `~/.codex/config.toml`) are denied even though the surrounding state dir is writableboth close the host-CLI persistence vector.
48
+ - **Reads**: the home region (`/Users` on macOS, `/home`+`/root`+`/mnt` on Linux — `/mnt` covers WSL's Windows drive mounts) is denied, then the worktree, the repo's git metadata, the language toolchains needed to run the agent, and the agent's own config dirs (`~/.claude`, `~/.codex`, …) are re-opened. On macOS the user keychain dir (`~/Library/Keychains`) is also re-opened read-only so keychain-authenticated agents (claude) can sign in under the home mask. The agent cannot read `~/.ssh`, `~/.aws`, shell history, or unrelated repos.
49
+ - **Writes**: allow-only, and the host-CLI persistence vector (planting hooks, `mcpServers`, `commands/`, `plugins/`, that run on the user's next host invocation) is closed per agent. **claude** keeps a writable `~/.claude` (its Bash tool needs scratch/session state there) but every fixed-path executable/instruction surface `~/.claude.json` (`mcpServers`), `settings.json` and its hooks, `commands/`, `agents/`, `plugins/`, `skills/`, `statusline.sh`, `CLAUDE.md`, the bundled `chrome` binary, `.git/{hooks,config}` — is denied; claude tolerates those write denials. **codex** hard-fails with a read-only home, so it is pointed at a per-launch relocated config dir (`CODEX_HOME`) seeded with its credentials, leaving the real `~/.codex` entirely unwritten. The git common dir is granted as a **narrow allowlist** of only what `status/diff/add/commit/push/gc` write (`objects`, `refs`, `logs`, `packed-refs`, this worktree's gitdir, …) never wholesale, so the repo `config`/`hooks`, the per-worktree gitdir redirection files, and **sibling worktree gitdirs** stay unwritable. Global toolchain bins (`~/.cargo/bin`, global `node_modules`, the npx cache, …) are never writable either.
50
50
  - **Environment**: each `srt` invocation runs under a sanitized env (`env -i` + a benign baseline). Unlike safehouse and sdx, the `srt` CLI inherits the host env, so without this an ambient `AWS_*`, `GITHUB_TOKEN`, etc. would reach the agent and bypass the read mask. Credentials the agent legitimately needs from the environment must be forwarded explicitly via the model's `preLaunchEnv` (the same opt-in pass-list safehouse uses).
51
51
  - **Network**: allow-only, **reused from the same Clearance allowlist** (`CLEARANCE_ALLOW_HOSTS` / `CLEARANCE_ALLOW_HOSTS_FILES`, including the shipped `clearance-allow-hosts`) so there is one source of truth. Local binding and unix sockets stay off (never the Docker socket).
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",
@@ -84,12 +84,12 @@
84
84
  "@types/node": "25.9.1",
85
85
  "@typescript/native-preview": "7.0.0-dev.20260527.2",
86
86
  "@vitest/coverage-v8": "4.1.7",
87
- "cspell": "10.0.0",
87
+ "cspell": "10.0.1",
88
88
  "dependency-cruiser": "17.4.3",
89
89
  "husky": "9.1.7",
90
90
  "jscpd": "4.2.4",
91
91
  "knip": "6.14.2",
92
- "lint-staged": "17.0.5",
92
+ "lint-staged": "17.0.7",
93
93
  "markdownlint-cli2": "0.22.1",
94
94
  "nx": "22.7.3",
95
95
  "oxfmt": "0.52.0",