@zgeoff/atc 2.38.0 → 2.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.38.0",
3
+ "version": "2.39.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -86,6 +86,22 @@ export interface GuestSpawnPlan extends SpawnPlan {
86
86
  readonly env?: Readonly<Record<string, string>>;
87
87
  }
88
88
 
89
+ /**
90
+ * The project settings files the agent applies on top of what atc passes
91
+ * it, and the refusal for a file whose content would override or route
92
+ * around a sign-in through impd's broker, or null when it holds nothing
93
+ * that would. `files` are relative to the directory a harness starts in;
94
+ * `rootFiles` are relative to that directory, each directory above it, and
95
+ * the main checkout of the git worktree it lies in, since the agent reads
96
+ * them at a repository's root. `findRefusal` takes the file's absolute path
97
+ * on the host.
98
+ */
99
+ export interface ProjectSettingsCheck {
100
+ readonly files: readonly string[];
101
+ readonly rootFiles: readonly string[];
102
+ readonly findRefusal: (path: string, content: string) => DaemonError | null;
103
+ }
104
+
89
105
  export interface TranscriptToolUse {
90
106
  readonly name: string;
91
107
  readonly input: string;
@@ -269,6 +285,11 @@ export interface AgentAdapter {
269
285
  // The command that exits 0 inside a remote host when the agent there can
270
286
  // sign in without a person. Absent: atc runs no check.
271
287
  readonly planAuthCheck?: () => readonly string[];
288
+
289
+ // The project settings a remote launch behind impd's broker reads in
290
+ // the directory the harness starts in, before it starts. Absent: atc
291
+ // reads none.
292
+ readonly planProjectSettingsCheck?: () => ProjectSettingsCheck;
272
293
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
273
294
  readonly loadName: (
274
295
  source: string,
@@ -0,0 +1,74 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import { isSubscriptionOverrideVariable } from '../shared/is-subscription-override-variable';
3
+ import { isRecord } from '../shared/report';
4
+ import type { ProjectSettingsCheck } from './agent-adapter';
5
+
6
+ /**
7
+ * The check of a Claude CLI session's project settings before a launch
8
+ * behind impd's broker. The CLI applies a repository's
9
+ * `.claude/settings.json` from the directory it starts in, and its
10
+ * `.claude/settings.local.json` from there and from the repository's root
11
+ * or a worktree's main checkout, once the folder is trusted; a person can
12
+ * accept that trust inside the session. Each file refuses the launch when
13
+ * its `env` block sets a variable that would override the sign-in or route
14
+ * around the broker, or when it sets `apiKeyHelper`. A file that is not a
15
+ * JSON object refuses it too, since what the CLI would take from it is
16
+ * unknown. A refusal holds the setting's name, never its value.
17
+ */
18
+ export function buildClaudeProjectSettingsCheck(agent: string): ProjectSettingsCheck {
19
+ return {
20
+ files: ['.claude/settings.json', LOCAL_SETTINGS_FILE],
21
+ rootFiles: [LOCAL_SETTINGS_FILE],
22
+ findRefusal: (file, content) => {
23
+ const setting = findOverrideSetting(content);
24
+
25
+ if (setting === null) {
26
+ return null;
27
+ }
28
+
29
+ const message =
30
+ setting === UNPARSEABLE
31
+ ? `agent '${agent}' signs in through impd's broker, but ${file} is not a JSON object, so atc cannot check it for settings that would override that sign-in`
32
+ : `agent '${agent}' signs in through impd's broker, but ${file} sets ${setting}, which would override or route around that sign-in`;
33
+
34
+ return new DaemonError('auth_target_unsupported', message, {
35
+ agent,
36
+ problem: 'project_settings_conflict',
37
+ file,
38
+ setting,
39
+ });
40
+ },
41
+ };
42
+ }
43
+
44
+ const LOCAL_SETTINGS_FILE = '.claude/settings.local.json';
45
+ const UNPARSEABLE = '(unparseable)';
46
+
47
+ // The first setting a project settings file holds that would override the
48
+ // sign-in, as `env.<variable>` or `apiKeyHelper`, the unparseable marker
49
+ // for content that is not a JSON object, or null when it holds none.
50
+ function findOverrideSetting(content: string): string | null {
51
+ let parsed: unknown;
52
+
53
+ try {
54
+ parsed = JSON.parse(content);
55
+ } catch {
56
+ return UNPARSEABLE;
57
+ }
58
+
59
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
60
+ return UNPARSEABLE;
61
+ }
62
+
63
+ if (parsed['apiKeyHelper'] !== undefined) {
64
+ return 'apiKeyHelper';
65
+ }
66
+
67
+ const env = parsed['env'];
68
+
69
+ const variable = isRecord(env)
70
+ ? Object.keys(env).find((key) => isSubscriptionOverrideVariable(key))
71
+ : undefined;
72
+
73
+ return variable === undefined ? null : `env.${variable}`;
74
+ }
@@ -23,6 +23,7 @@ import type {
23
23
  GuestSpawnPlan,
24
24
  HeadlessRunner,
25
25
  NameUpdate,
26
+ ProjectSettingsCheck,
26
27
  ResumeCheck,
27
28
  SpawnOptionSpecs,
28
29
  SpawnOptions,
@@ -34,6 +35,7 @@ import { buildClaudeConfigSeed } from './build-claude-config-seed';
34
35
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
35
36
  import { buildClaudeMCPConfig } from './build-claude-mcp-config';
36
37
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
38
+ import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
37
39
  import { buildHookSettings } from './build-hook-settings';
38
40
  import type { HookSettingsProfile } from './build-hook-settings';
39
41
  import { buildRestoreModeArgs } from './build-restore-mode-args';
@@ -226,6 +228,12 @@ export class ClaudeAdapter implements AgentAdapter {
226
228
  return buildClaudeConfigSeed(root);
227
229
  }
228
230
 
231
+ // The repository's own settings files outrank what the session's user
232
+ // settings hold, so a launch behind impd's broker reads them first.
233
+ planProjectSettingsCheck(): ProjectSettingsCheck {
234
+ return buildClaudeProjectSettingsCheck(this.id);
235
+ }
236
+
229
237
  // A settings file of the session's own per binding revision carries the
230
238
  // placeholder, and so does the CLI's environment. The configured
231
239
  // arguments go without a permission mode, so the mode the session's own
@@ -17,6 +17,7 @@ import type {
17
17
  GuestSpawnPlan,
18
18
  HeadlessRunner,
19
19
  NameUpdate,
20
+ ProjectSettingsCheck,
20
21
  ResumeCheck,
21
22
  SpawnOptionSpecs,
22
23
  SpawnOptions,
@@ -26,6 +27,7 @@ import { buildATCBridgeFiles } from './build-atc-bridge-files';
26
27
  import { buildClaudeConfigSeed } from './build-claude-config-seed';
27
28
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
28
29
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
30
+ import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
29
31
  import { buildHookSettings } from './build-hook-settings';
30
32
  import { buildRestoreModeArgs } from './build-restore-mode-args';
31
33
  import { ClaudeAdapter } from './claude-adapter';
@@ -335,6 +337,12 @@ export class GatewayAdapter implements AgentAdapter {
335
337
  ];
336
338
  }
337
339
 
340
+ // The repository's own settings files reach the CLI as well, so a launch
341
+ // behind impd's broker reads them first.
342
+ planProjectSettingsCheck(): ProjectSettingsCheck {
343
+ return buildClaudeProjectSettingsCheck(this.id);
344
+ }
345
+
338
346
  private buildBrokerRefusal(reason: string): DaemonError {
339
347
  return new DaemonError(
340
348
  'auth_target_unsupported',
@@ -27,6 +27,7 @@ import { truncateDetail } from '../shared/truncate-detail';
27
27
  import { truncateToBytes } from '../shared/truncate-to-bytes';
28
28
  import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
29
29
  import type { SessionWorkspace } from '../store/workspace-materialization';
30
+ import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
30
31
  import type { BrokerAuthHost } from './broker-auth-host';
31
32
  import { buildAuthBinding } from './build-auth-binding';
32
33
  import type { AuthBinding } from './build-auth-binding';
@@ -241,6 +242,31 @@ const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=
241
242
  const REMOVE_DIR_SCRIPT =
242
243
  'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
243
244
 
245
+ // Enters a directory as the host resolves it and prints, each followed by
246
+ // a NUL, every path where something, even a dangling symlink, stands: each
247
+ // start file in that directory, then each root file in that directory,
248
+ // every directory above it, and the main checkout of the git worktree it
249
+ // lies in. A `--` argument ends the start files. A directory that does not
250
+ // exist exits with the absent status.
251
+ const FIND_PROJECT_SETTINGS_SCRIPT = `cd -P -- "$1" 2>/dev/null || exit 3; shift; d=$(pwd -P)
252
+ emit() { if [ -e "$1" ] || [ -L "$1" ]; then printf '%s\\0' "$1"; fi; }
253
+ while [ "$#" -gt 0 ] && [ "$1" != -- ]; do emit "\${d%/}/$1"; shift; done; [ "$#" -gt 0 ] && shift
254
+ m=$(git -c safe.directory='*' -c core.fsmonitor=false rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && m=\${m%/.git} || m=
255
+ for n in "$@"; do p=$d; while :; do emit "\${p%/}/$n"; [ "$p" = / ] && break; p=\${p%/*}; [ -n "$p" ] || p=/; done; [ -z "$m" ] || emit "\${m%/}/$n"; done
256
+ exit 0`;
257
+
258
+ // Prints at most one byte more than the largest project settings file atc
259
+ // reads, following a symlink, and fails with the special-file status for
260
+ // anything but a regular file there, such as a FIFO or a device that would
261
+ // never end.
262
+ const READ_SETTINGS_FILE = '[ -f "$1" ] || exit 4; exec head -c 1048577 -- "$1"';
263
+ const ABSENT_DIR_EXIT = 3;
264
+
265
+ // Runs git on the directory it starts in, whatever repository the daemon's
266
+ // environment pins git to.
267
+ const GIT_ENV_ARGV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
268
+ const MAX_SETTINGS_BYTES = 1_048_576;
269
+
244
270
  // A readied host's harness plan, and the auth attempt that provisioned the
245
271
  // host, if one did.
246
272
  interface HarnessSetup {
@@ -693,6 +719,7 @@ export class SessionManager {
693
719
  ...(s.effort === undefined ? {} : { effort: s.effort }),
694
720
  },
695
721
  authSetup,
722
+ s.cwd,
696
723
  );
697
724
 
698
725
  plan = setup.plan;
@@ -999,6 +1026,10 @@ export class SessionManager {
999
1026
  }
1000
1027
  }
1001
1028
 
1029
+ // A spawn that builds its workspace reads the workspace's project
1030
+ // settings once it is built, not when the host is readied before it.
1031
+ const startDir = materialize === null ? cwd : null;
1032
+
1002
1033
  const setupHost = () =>
1003
1034
  this.setupHarnessOnHost(
1004
1035
  adapter,
@@ -1008,9 +1039,17 @@ export class SessionManager {
1008
1039
  target,
1009
1040
  { prompt, resume, ...overrides },
1010
1041
  authSetup,
1042
+ startDir,
1011
1043
  hostKey === id,
1012
1044
  );
1013
1045
 
1046
+ // A launch behind the broker reads the project settings of the clone it
1047
+ // starts in, once the clone is on the host and before it is trusted.
1048
+ const checkWorkspace =
1049
+ auth === null
1050
+ ? null
1051
+ : (root: string) => this.requireProjectSettings(adapter, provider, hostKey, target, root);
1052
+
1014
1053
  // Takes back trust a spawn accepted in the user's own agent config when
1015
1054
  // the spawn fails before its harness starts.
1016
1055
  const localTrust: { remove: (() => Promise<void>) | null } = { remove: null };
@@ -1061,6 +1100,7 @@ export class SessionManager {
1061
1100
  execution.identity,
1062
1101
  materialize,
1063
1102
  setupHost,
1103
+ checkWorkspace,
1064
1104
  trustWorkspace,
1065
1105
  );
1066
1106
  });
@@ -1591,7 +1631,9 @@ export class SessionManager {
1591
1631
  // Materializes a spawn's workspace, readying its host once the source
1592
1632
  // resolves, or after the workspace for a directory that runs as it
1593
1633
  // stands, and returns the directory it created, null for one that runs
1594
- // as it stands. A failure once the host is ready takes it back.
1634
+ // as it stands. A clone is checked and then trusted, either as the caller
1635
+ // asks, and a failure there removes it. A failure once the host is ready
1636
+ // takes it back.
1595
1637
  private async materializeOnSpawnHost(
1596
1638
  provider: ExecutionProvider,
1597
1639
  id: SessionID,
@@ -1601,6 +1643,7 @@ export class SessionManager {
1601
1643
  targetIdentity: string,
1602
1644
  materialize: SpawnMaterializer,
1603
1645
  setupHost: () => Promise<HarnessSetup>,
1646
+ checkWorkspace: ((root: string) => Promise<void>) | null,
1604
1647
  trustWorkspace: ((root: string) => Promise<void>) | null,
1605
1648
  ): Promise<{
1606
1649
  readonly setup: HarnessSetup;
@@ -1644,16 +1687,17 @@ export class SessionManager {
1644
1687
 
1645
1688
  readied.setup ??= await setupHost();
1646
1689
 
1647
- if (trustWorkspace !== null) {
1648
- if (materialized === null || readied.root === null) {
1649
- throw new DaemonError(
1650
- 'bad_args',
1651
- 'trustClonedWorkspace requires a successfully cloned workspace',
1652
- );
1653
- }
1690
+ if (trustWorkspace !== null && (materialized === null || readied.root === null)) {
1691
+ throw new DaemonError(
1692
+ 'bad_args',
1693
+ 'trustClonedWorkspace requires a successfully cloned workspace',
1694
+ );
1695
+ }
1654
1696
 
1697
+ if (materialized !== null && readied.root !== null) {
1655
1698
  try {
1656
- await trustWorkspace(readied.root);
1699
+ await checkWorkspace?.(readied.root);
1700
+ await trustWorkspace?.(readied.root);
1657
1701
  } catch (error) {
1658
1702
  let removed = false;
1659
1703
 
@@ -1665,7 +1709,7 @@ export class SessionManager {
1665
1709
 
1666
1710
  if (!removed) {
1667
1711
  throw new EffectRemainsError(
1668
- 'workspace trust setup failed and its clone could not be removed',
1712
+ 'workspace setup failed and its clone could not be removed',
1669
1713
  { cause: error },
1670
1714
  );
1671
1715
  }
@@ -1906,9 +1950,10 @@ export class SessionManager {
1906
1950
  target: string,
1907
1951
  options: SpawnOptions,
1908
1952
  auth: HarnessAuthSetup | null,
1953
+ dir: string,
1909
1954
  ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1910
1955
  return this.withHostReadying(hostKey, () =>
1911
- this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, false),
1956
+ this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
1912
1957
  );
1913
1958
  }
1914
1959
 
@@ -1938,6 +1983,11 @@ export class SessionManager {
1938
1983
  options: SpawnOptions,
1939
1984
  auth: HarnessAuthSetup | null,
1940
1985
 
1986
+ // The directory the harness starts in, whose project settings a launch
1987
+ // behind the broker reads once the host is ready; null for a spawn
1988
+ // whose workspace is built after this, which reads them once it is.
1989
+ cwd: string | null,
1990
+
1941
1991
  // Whether the host is a spawn's new host of its own, which a failure
1942
1992
  // once it is readied destroys.
1943
1993
  isNewHost: boolean,
@@ -2000,6 +2050,10 @@ export class SessionManager {
2000
2050
 
2001
2051
  try {
2002
2052
  await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
2053
+
2054
+ if (auth !== null && cwd !== null) {
2055
+ await this.requireProjectSettings(adapter, provider, hostKey, target, cwd);
2056
+ }
2003
2057
  } catch (error) {
2004
2058
  if (attemptID !== null || isNewHost) {
2005
2059
  await this.destroyFailedSpawnHost(provider, id, hostKey, attemptID);
@@ -2135,6 +2189,84 @@ export class SessionManager {
2135
2189
  }
2136
2190
  }
2137
2191
 
2192
+ // Reads each project settings file the agent applies for a harness behind
2193
+ // the broker, from the directory it starts in as the host resolves it, and
2194
+ // throws the agent's refusal for one that would override its sign-in. An
2195
+ // absent file passes; one the host holds but cannot read, that is not a
2196
+ // regular file, or that is larger than any settings file refuses, since
2197
+ // what the agent would take from it is unknown. A relative directory is
2198
+ // under the host's home.
2199
+ private async requireProjectSettings(
2200
+ adapter: AgentAdapter,
2201
+ provider: ExecutionProvider,
2202
+ hostKey: SessionID,
2203
+ target: string,
2204
+ cwd: string,
2205
+ ): Promise<void> {
2206
+ const check = adapter.planProjectSettingsCheck?.();
2207
+
2208
+ if (check === undefined) {
2209
+ return;
2210
+ }
2211
+
2212
+ // The host resolves the directory as given, since a lexical join would
2213
+ // drop a parent step after a symlink that the host follows.
2214
+ const home = posix.isAbsolute(cwd) ? null : await this.resolveHostHome(provider, hostKey);
2215
+ const dir = home === null ? cwd : `${home}/${cwd}`;
2216
+
2217
+ const found = await provider.runCommand({
2218
+ argv: [
2219
+ ...GIT_ENV_ARGV,
2220
+ 'sh',
2221
+ '-c',
2222
+ FIND_PROJECT_SETTINGS_SCRIPT,
2223
+ 'sh',
2224
+ dir,
2225
+ ...check.files,
2226
+ '--',
2227
+ ...check.rootFiles,
2228
+ ],
2229
+ cwd: '/',
2230
+ host: hostKey,
2231
+ });
2232
+
2233
+ if (found.exitCode === ABSENT_DIR_EXIT) {
2234
+ return;
2235
+ }
2236
+
2237
+ if (found.exitCode !== 0) {
2238
+ throw this.buildUnreadableSettingsRefusal(adapter.id, target, dir);
2239
+ }
2240
+
2241
+ const paths = new Set(found.stdout.split('\0').filter((path) => path !== ''));
2242
+
2243
+ for (const path of paths) {
2244
+ const read = await provider.runCommand({
2245
+ argv: ['sh', '-c', READ_SETTINGS_FILE, 'sh', path],
2246
+ cwd: '/',
2247
+ host: hostKey,
2248
+ });
2249
+
2250
+ if (read.exitCode !== 0 || Buffer.byteLength(read.stdout) > MAX_SETTINGS_BYTES) {
2251
+ throw this.buildUnreadableSettingsRefusal(adapter.id, target, path);
2252
+ }
2253
+
2254
+ const refusal = check.findRefusal(path, read.stdout);
2255
+
2256
+ if (refusal !== null) {
2257
+ throw new DaemonError(refusal.code, refusal.message, { ...refusal.data, target });
2258
+ }
2259
+ }
2260
+ }
2261
+
2262
+ private buildUnreadableSettingsRefusal(agent: string, target: string, path: string): DaemonError {
2263
+ return new DaemonError(
2264
+ 'auth_target_unsupported',
2265
+ `agent '${agent}' signs in through impd's broker on target '${target}', but atc cannot read ${path} there as a settings file to check it for settings that would override that sign-in`,
2266
+ { agent, target, problem: 'project_settings_unreadable', file: path },
2267
+ );
2268
+ }
2269
+
2138
2270
  // A sub-session runs on its parent's host when its resolved target, name
2139
2271
  // and identity both, is the one its parent is bound to and that target's
2140
2272
  // hosts have a lifecycle, so one host serves a top-level session and