@clipboard-health/groundcrew 4.12.0 → 4.13.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/dist/commands/resumeWorkspace.d.ts.map +1 -1
- package/dist/commands/resumeWorkspace.js +30 -0
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +8 -34
- package/dist/lib/launchCommand.d.ts +12 -0
- package/dist/lib/launchCommand.d.ts.map +1 -1
- package/dist/lib/launchCommand.js +8 -1
- package/dist/lib/srtLaunch.d.ts +47 -0
- package/dist/lib/srtLaunch.d.ts.map +1 -0
- package/dist/lib/srtLaunch.js +84 -0
- package/dist/lib/srtPolicy.d.ts +64 -10
- package/dist/lib/srtPolicy.d.ts.map +1 -1
- package/dist/lib/srtPolicy.js +150 -61
- package/dist/lib/stagedLaunch.d.ts +0 -22
- package/dist/lib/stagedLaunch.d.ts.map +1 -1
- package/dist/lib/stagedLaunch.js +0 -18
- package/docs/runners.md +2 -2
- package/package.json +2 -2
|
@@ -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;
|
|
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":"
|
|
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 {
|
|
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 {
|
|
12
|
-
import { stageBuildSecrets, stagePromptFromTemplate,
|
|
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
|
|
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 =
|
|
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;
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/srtPolicy.d.ts
CHANGED
|
@@ -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,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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"}
|
package/dist/lib/srtPolicy.js
CHANGED
|
@@ -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,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
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`
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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"
|
|
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: [
|
|
71
|
-
denyPaths: [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
119
|
-
* shouldn't write `~/.claude`) and to
|
|
120
|
-
* path `~/.claude/debug`, which
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
190
|
-
// on the user's next host invocation (denyWrite wins over
|
|
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
|
|
194
|
-
// profile, and keeps profiles from writing each other's
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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":"
|
|
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"}
|
package/dist/lib/stagedLaunch.js
CHANGED
|
@@ -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
|
|
49
|
-
- **Writes**: allow-only
|
|
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.
|
|
3
|
+
"version": "4.13.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,7 +84,7 @@
|
|
|
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.
|
|
87
|
+
"cspell": "10.0.1",
|
|
88
88
|
"dependency-cruiser": "17.4.3",
|
|
89
89
|
"husky": "9.1.7",
|
|
90
90
|
"jscpd": "4.2.4",
|