@zgeoff/atc 2.15.2 → 2.16.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.15.2",
3
+ "version": "2.16.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",
@@ -43,7 +43,7 @@
43
43
  "@better-auth/oauth-provider": "1.7.6",
44
44
  "@xterm/addon-serialize": "0.14.0",
45
45
  "@xterm/headless": "6.0.0",
46
- "@zgeoff/imp-client": "0.24.0",
46
+ "@zgeoff/imp-client": "0.27.0",
47
47
  "better-auth": "1.7.6",
48
48
  "bun-pty": "0.4.10",
49
49
  "citty": "0.2.2",
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentID } from '../shared/agent-id';
3
+ import { toShellArg } from '../shared/to-shell-arg';
3
4
  import { buildCLICommand } from './build-cli-command';
4
5
 
5
6
  /**
@@ -20,7 +21,9 @@ export interface HookSettingsProfile {
20
21
  * The settings object injected into wrangled sessions via
21
22
  * `claude --settings`. The user's own settings are untouched; these hooks
22
23
  * only exist in sessions atc spawns, and identify themselves via
23
- * ATC_SESSION_ID in the env.
24
+ * ATC_SESSION_ID in the env and the agent id on their command line. A
25
+ * nested harness inherits the env but not the command line, so the agent id
26
+ * keeps its reports from passing as the session's own.
24
27
  *
25
28
  * A settings-file env block outranks a shell export of the same variable, so
26
29
  * a session's backend is decided here rather than by whatever the terminal
@@ -37,7 +40,13 @@ export function buildHookSettings(
37
40
  ): Record<string, unknown> {
38
41
  const entry = [
39
42
  {
40
- hooks: [{ type: 'command', command: buildCLICommand('hook-report', cliArgv), timeout: 5 }],
43
+ hooks: [
44
+ {
45
+ type: 'command',
46
+ command: buildCLICommand(`hook-report --agent ${toShellArg(profile.id)}`, cliArgv),
47
+ timeout: 5,
48
+ },
49
+ ],
41
50
  },
42
51
  ];
43
52
 
@@ -61,7 +70,7 @@ export function buildHookSettings(
61
70
  // padding.
62
71
  statusLine: {
63
72
  type: 'command',
64
- command: buildCLICommand('statusline', cliArgv),
73
+ command: buildCLICommand(`statusline --agent ${toShellArg(profile.id)}`, cliArgv),
65
74
  padding: statuslinePadding,
66
75
  },
67
76
  ...(profile.env === undefined || Object.keys(profile.env).length === 0
@@ -17,7 +17,7 @@ export function printCodexHookFile(): void {
17
17
  }
18
18
 
19
19
  function buildCodexHookFile(): string {
20
- const cmd = buildCLICommand('hook-report');
20
+ const cmd = buildCLICommand('hook-report --agent codex');
21
21
  const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
22
22
  const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
23
23
 
@@ -19,7 +19,7 @@ export function printGrokHookFile(): void {
19
19
  }
20
20
 
21
21
  function buildGrokHookFile(): string {
22
- const cmd = buildCLICommand('hook-report');
22
+ const cmd = buildCLICommand('hook-report --agent grok');
23
23
  const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
24
24
  const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
25
25
 
package/src/cli.ts CHANGED
@@ -291,10 +291,16 @@ const main = defineCommand({
291
291
  description: 'Forward a hook event from a wrangled session to the atc socket',
292
292
  hidden: true,
293
293
  },
294
- async run() {
294
+
295
+ // No arg is required: a citty usage error exits nonzero, and
296
+ // reporters must always exit 0.
297
+ args: {
298
+ agent: { type: 'string', default: '' },
299
+ },
300
+ async run(ctx) {
295
301
  const reporter = await import('./hook-report');
296
302
 
297
- await reporter.runHookReport();
303
+ await reporter.runHookReport(ctx.args.agent);
298
304
  },
299
305
  }),
300
306
  tap: () =>
@@ -352,10 +358,15 @@ const main = defineCommand({
352
358
  description: 'Render the chained statusline for a wrangled session',
353
359
  hidden: true,
354
360
  },
355
- async run() {
361
+
362
+ // A statusline command must always exit 0 too, so no arg is required.
363
+ args: {
364
+ agent: { type: 'string', default: '' },
365
+ },
366
+ async run(ctx) {
356
367
  const statusline = await import('./statusline');
357
368
 
358
- await statusline.runStatusline();
369
+ await statusline.runStatusline(ctx.args.agent);
359
370
  },
360
371
  }),
361
372
  },
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Why impd's credential broker may not be used for a session, or why atc
3
+ * may not clean up after one:
4
+ *
5
+ * - `auth_impd_too_old`: impd lacks grantable tokens or secret rebinds.
6
+ * - `auth_token_scope`: the token's scope is below `manage`.
7
+ * - `auth_token_too_broad`: the token can reach imps outside atc's
8
+ * namespace, through no imp patterns or a pattern whose literal text
9
+ * before its first `*` does not start with the namespace prefix.
10
+ * - `auth_imp_out_of_scope`: an imp the call touches is outside the
11
+ * token's patterns.
12
+ * - `auth_secret_not_grantable`: a bound secret is not on the token's
13
+ * grantable list.
14
+ * - `auth_runtime_mismatch`: the imp under the recorded name has another
15
+ * id than the one recorded.
16
+ */
17
+ export type BrokerAuthorityCode =
18
+ | 'auth_impd_too_old'
19
+ | 'auth_token_scope'
20
+ | 'auth_token_too_broad'
21
+ | 'auth_imp_out_of_scope'
22
+ | 'auth_secret_not_grantable'
23
+ | 'auth_runtime_mismatch';
24
+
25
+ /**
26
+ * A refusal to provision through impd's credential broker or to clean up
27
+ * after it, carrying its code and the detail the code defines.
28
+ */
29
+ export class BrokerAuthorityError extends Error {
30
+ readonly code: BrokerAuthorityCode;
31
+
32
+ readonly data: Readonly<Record<string, unknown>>;
33
+
34
+ constructor(code: BrokerAuthorityCode, message: string, data: Readonly<Record<string, unknown>>) {
35
+ super(message);
36
+
37
+ this.code = code;
38
+ this.data = data;
39
+ this.name = 'BrokerAuthorityError';
40
+ }
41
+ }
@@ -0,0 +1,83 @@
1
+ import type { ImpSecret, ImpSecretRule } from './imp-port';
2
+
3
+ /**
4
+ * One secret as a resolved binding expects impd to hold it: its kind and
5
+ * the complete set of rules, one per host.
6
+ */
7
+ export interface ExpectedSecret {
8
+ readonly secret: string;
9
+ readonly kind: ImpSecret['kind'];
10
+ readonly rules: readonly ImpSecretRule[];
11
+ }
12
+
13
+ export type SecretRuleMismatch =
14
+ | { readonly secret: string; readonly reason: 'missing' }
15
+ | {
16
+ readonly secret: string;
17
+ readonly reason: 'kind';
18
+ readonly expected: ImpSecret['kind'];
19
+ readonly actual: ImpSecret['kind'];
20
+ }
21
+ | {
22
+ readonly secret: string;
23
+ readonly reason: 'rules';
24
+ readonly expected: readonly ImpSecretRule[];
25
+ readonly actual: readonly ImpSecretRule[];
26
+ };
27
+
28
+ /**
29
+ * Compares the secrets a resolved binding expects with the secrets impd
30
+ * lists, secret by secret. Each expected secret must exist with the same
31
+ * kind and exactly the same set of rules, in any order: a rule impd holds
32
+ * that the binding lacks is a mismatch as much as a missing one, since it
33
+ * sends the credential to a host the binding never approved. An empty
34
+ * result means every expected secret matches; secrets the binding does not
35
+ * expect are ignored.
36
+ */
37
+ export function collectSecretRuleMismatches(
38
+ expected: readonly ExpectedSecret[],
39
+ secrets: readonly ImpSecret[],
40
+ ): SecretRuleMismatch[] {
41
+ const held = new Map(secrets.map((secret) => [secret.name, secret]));
42
+
43
+ const mismatches: SecretRuleMismatch[] = [];
44
+
45
+ for (const want of expected) {
46
+ const actual = held.get(want.secret);
47
+
48
+ if (actual === undefined) {
49
+ mismatches.push({ secret: want.secret, reason: 'missing' });
50
+ } else if (actual.kind !== want.kind) {
51
+ mismatches.push({
52
+ secret: want.secret,
53
+ reason: 'kind',
54
+ expected: want.kind,
55
+ actual: actual.kind,
56
+ });
57
+ } else if (!hasSameRules(want.rules, actual.rules)) {
58
+ mismatches.push({
59
+ secret: want.secret,
60
+ reason: 'rules',
61
+ expected: want.rules,
62
+ actual: actual.rules,
63
+ });
64
+ }
65
+ }
66
+
67
+ return mismatches;
68
+ }
69
+
70
+ function hasSameRules(left: readonly ImpSecretRule[], right: readonly ImpSecretRule[]): boolean {
71
+ const leftKeys = left.map((rule) => toRuleKey(rule)).toSorted();
72
+ const rightKeys = right.map((rule) => toRuleKey(rule)).toSorted();
73
+
74
+ return (
75
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
76
+ );
77
+ }
78
+
79
+ // A rule as one comparable string; JSON keeps a missing user apart from
80
+ // any user a basic rule can hold.
81
+ function toRuleKey(rule: ImpSecretRule): string {
82
+ return JSON.stringify([rule.host, rule.header, rule.scheme, rule.user ?? null]);
83
+ }
@@ -58,6 +58,7 @@ import { EffectRemainsError } from './effect-remains-error';
58
58
  import { EventSignal } from './event-signal';
59
59
  import { startHookServer } from './hooks';
60
60
  import { IdempotencyLedger } from './idempotency-ledger';
61
+ import { isOwnHookEvent } from './is-own-hook-event';
61
62
  import { isTreeInReach } from './is-tree-in-reach';
62
63
  import { loadTranscriptPage } from './load-transcript-page';
63
64
  import { makeHookRunner } from './make-hook-runner';
@@ -765,6 +766,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
765
766
  }
766
767
 
767
768
  const before = mgr.sessions.find((s) => s.id === e.atcId);
769
+ const runtime = runtimes.get(e.atcId);
770
+
771
+ // A harness nested inside a session inherits its environment and reports
772
+ // under its id; only the harness atc started may change the session.
773
+ if (
774
+ before !== undefined &&
775
+ runtime !== undefined &&
776
+ !isOwnHookEvent(e.agent, before.agent, runtime.hasAgentHookLines)
777
+ ) {
778
+ return;
779
+ }
780
+
781
+ if (runtime !== undefined && e.agent !== undefined) {
782
+ runtime.hasAgentHookLines = true;
783
+ }
784
+
768
785
  const previousAgentSessionID = before?.agentSessionID;
769
786
  const ev = mgr.applyHook(e);
770
787
 
@@ -777,7 +794,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
777
794
  }
778
795
 
779
796
  const kind = ev?.kind ?? null;
780
- const runtime = runtimes.get(e.atcId);
781
797
  const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
782
798
 
783
799
  if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
@@ -1,14 +1,17 @@
1
1
  import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
2
2
  import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
3
+ import { isRecord } from '../shared/report';
3
4
  import type {
4
5
  ImpCommand,
5
6
  ImpCommandResult,
6
7
  ImpCreateSpec,
7
8
  ImpFeatures,
9
+ ImpIdentity,
8
10
  ImpLease,
9
11
  ImpPort,
10
12
  ImpRelayConnection,
11
13
  ImpReverseForward,
14
+ ImpSecret,
12
15
  ImpSessionConnection,
13
16
  ImpSessionHandlers,
14
17
  ImpSessionOutcome,
@@ -48,11 +51,79 @@ export class ImpClientPort implements ImpPort {
48
51
  this.readToken = options.readToken;
49
52
  }
50
53
 
51
- // An impd from before the flags has neither.
54
+ // A flag counts only as a literal true: an impd from before a flag has
55
+ // it false, and so does one that sends anything else in its place.
52
56
  readonly readFeatures = async (): Promise<ImpFeatures> => {
53
57
  const info = await this.tryCall((client) => client.system.info());
54
58
 
55
- return info.features ?? { sessionOffsets: false, leases: false };
59
+ const features: Readonly<Record<string, unknown>> | undefined = info.features;
60
+
61
+ return {
62
+ sessionOffsets: features?.['sessionOffsets'] === true,
63
+ leases: features?.['leases'] === true,
64
+ grantableTokens: features?.['grantableTokens'] === true,
65
+ secretRebind: features?.['secretRebind'] === true,
66
+ };
67
+ };
68
+
69
+ // An impd from before grantable lists sends none, which grants nothing.
70
+ readonly readIdentity = async (): Promise<ImpIdentity> => {
71
+ const identity = await this.tryCall((client) => client.tokens.whoami());
72
+
73
+ return {
74
+ kind: identity.kind,
75
+ name: identity.name,
76
+ scope: identity.scope,
77
+ imps: identity.imps === null ? null : [...identity.imps],
78
+ grantable: [...(identity.grantable ?? [])],
79
+ };
80
+ };
81
+
82
+ readonly readSecrets = async (): Promise<readonly ImpSecret[]> => {
83
+ const secrets = await this.tryCall((client) => client.secrets.list());
84
+
85
+ return secrets.map((secret) => ({
86
+ name: secret.name,
87
+ kind: secret.kind,
88
+ rules: secret.rules.map((rule) => ({
89
+ host: rule.host,
90
+ header: rule.header,
91
+ scheme: rule.scheme,
92
+ ...(rule.user === undefined ? {} : { user: rule.user }),
93
+ })),
94
+ imps: [...secret.imps],
95
+ }));
96
+ };
97
+
98
+ readonly readGrants = async (name: string): Promise<readonly string[]> => {
99
+ const grants = await this.tryCall((client) => client.grants.list({ name }));
100
+
101
+ return [...grants];
102
+ };
103
+
104
+ readonly createGrant = async (name: string, secret: string): Promise<void> => {
105
+ await this.tryCall((client) => client.grants.add({ name, secret }));
106
+ };
107
+
108
+ // A grant impd no longer holds is no grant to revoke; a missing imp or
109
+ // secret still rejects.
110
+ readonly removeGrant = async (name: string, secret: string): Promise<boolean> => {
111
+ try {
112
+ await this.tryCall((client) => client.grants.delete({ name, secret }));
113
+
114
+ return true;
115
+ } catch (error) {
116
+ if (
117
+ error instanceof ImpPortError &&
118
+ error.code === 'NOT_FOUND' &&
119
+ isRecord(error.data) &&
120
+ error.data['kind'] === 'grant'
121
+ ) {
122
+ return false;
123
+ }
124
+
125
+ throw error;
126
+ }
56
127
  };
57
128
 
58
129
  readonly readImp = async (name: string): Promise<ImpView | null> => {
@@ -60,6 +131,7 @@ export class ImpClientPort implements ImpPort {
60
131
  const imp = await this.tryCall((client) => client.imps.get({ name }));
61
132
 
62
133
  return {
134
+ id: imp.id,
63
135
  name: imp.name,
64
136
  state: imp.state,
65
137
  leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
@@ -83,7 +155,7 @@ export class ImpClientPort implements ImpPort {
83
155
  }),
84
156
  );
85
157
 
86
- return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
158
+ return { id: imp.id, name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
87
159
  };
88
160
 
89
161
  readonly acquireLease = async (
@@ -9,6 +9,23 @@ export interface ImpPort {
9
9
  // impd's capability flags; an impd without them has neither.
10
10
  readonly readFeatures: () => Promise<ImpFeatures>;
11
11
 
12
+ // The caller's own identity: its scope, the imps it may reach, and the
13
+ // secrets it may grant.
14
+ readonly readIdentity: () => Promise<ImpIdentity>;
15
+
16
+ // Every secret impd holds, with its rules and never its value.
17
+ readonly readSecrets: () => Promise<readonly ImpSecret[]>;
18
+
19
+ // The names of the secrets granted to an imp.
20
+ readonly readGrants: (name: string) => Promise<readonly string[]>;
21
+
22
+ // Grants a secret to an imp; granting one it already holds changes
23
+ // nothing, and nothing in the result distinguishes the two.
24
+ readonly createGrant: (name: string, secret: string) => Promise<void>;
25
+
26
+ // Revokes a secret from an imp, and reports whether impd held the grant.
27
+ readonly removeGrant: (name: string, secret: string) => Promise<boolean>;
28
+
12
29
  // The imp under a name, or null when impd holds none.
13
30
  readonly readImp: (name: string) => Promise<ImpView | null>;
14
31
  readonly createImp: (spec: ImpCreateSpec) => Promise<ImpView>;
@@ -51,11 +68,54 @@ export interface ImpPort {
51
68
  export interface ImpFeatures {
52
69
  readonly sessionOffsets: boolean;
53
70
  readonly leases: boolean;
71
+
72
+ // Tokens limited to some imps may grant a list of secrets to them.
73
+ readonly grantableTokens: boolean;
74
+
75
+ // A rebound or recreated secret drops its grants and leaves a token's
76
+ // list of grantable secrets behind.
77
+ readonly secretRebind: boolean;
78
+ }
79
+
80
+ type ImpScope = 'read' | 'exec' | 'manage';
81
+
82
+ export interface ImpIdentity {
83
+ readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard';
84
+ readonly name: string;
85
+ readonly scope: ImpScope;
86
+
87
+ // Imp name patterns with `*` for any run of characters; null reaches
88
+ // every imp on the host.
89
+ readonly imps: readonly string[] | null;
90
+
91
+ // The secrets the caller may grant to the imps it reaches and revoke
92
+ // from them.
93
+ readonly grantable: readonly string[];
94
+ }
95
+
96
+ export interface ImpSecret {
97
+ readonly name: string;
98
+ readonly kind: 'anthropic' | 'custom' | 'github' | 'npm';
99
+ readonly rules: readonly ImpSecretRule[];
100
+
101
+ // The imps holding a grant of the secret.
102
+ readonly imps: readonly string[];
103
+ }
104
+
105
+ // How impd adds a secret to requests for one host.
106
+ export interface ImpSecretRule {
107
+ readonly host: string;
108
+ readonly header: string;
109
+ readonly scheme: 'basic' | 'bearer' | 'raw';
110
+ readonly user?: string;
54
111
  }
55
112
 
56
113
  export type ImpState = 'creating' | 'running' | 'sleeping' | 'stopped' | 'error';
57
114
 
58
115
  export interface ImpView {
116
+ // impd's id for this imp, which a new imp made under the same name never
117
+ // shares.
118
+ readonly id: string;
59
119
  readonly name: string;
60
120
  readonly state: ImpState;
61
121
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether an imp name matches one of a token's imp patterns, as impd
3
+ * matches them: each pattern is an imp name with `*` for any run of
4
+ * characters, including none, so `dev-*` matches `dev-` and `dev-a1`, and
5
+ * a pattern without `*` matches only its own name.
6
+ */
7
+ export function isImpNameAllowed(patterns: readonly string[], name: string): boolean {
8
+ return patterns.some((pattern) => isPatternMatch(pattern, name));
9
+ }
10
+
11
+ function isPatternMatch(pattern: string, name: string): boolean {
12
+ const [first = '', ...rest] = pattern.split('*');
13
+ const last = rest.pop();
14
+
15
+ if (last === undefined) {
16
+ return name === first;
17
+ }
18
+
19
+ if (name.length < first.length + last.length || !name.startsWith(first) || !name.endsWith(last)) {
20
+ return false;
21
+ }
22
+
23
+ // Each middle part in order, between the prefix and the suffix.
24
+ let at = first.length;
25
+ const end = name.length - last.length;
26
+
27
+ for (const part of rest) {
28
+ const found = name.indexOf(part, at);
29
+
30
+ if (found === -1 || found + part.length > end) {
31
+ return false;
32
+ }
33
+
34
+ at = found + part.length;
35
+ }
36
+
37
+ return true;
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentID } from '../shared/agent-id';
2
+
3
+ /**
4
+ * Whether a hook line, judged by the agent it carries, comes from the
5
+ * harness atc started for its session rather than from a harness nested
6
+ * inside it, which inherits the session's environment and so reports under
7
+ * the same session. A line carrying an agent is the session's own when that
8
+ * agent is the session's. A line without one comes from a hook command that
9
+ * carries no agent flag, and stays the session's own until a line carrying
10
+ * the session's agent arrives from the same terminal: from then on the
11
+ * session's own hooks are known to carry it.
12
+ */
13
+ export function isOwnHookEvent(
14
+ lineAgent: AgentID | undefined,
15
+ sessionAgent: AgentID,
16
+ hasAgentHookLines: boolean,
17
+ ): boolean {
18
+ return lineAgent === undefined ? !hasAgentHookLines : lineAgent === sessionAgent;
19
+ }
@@ -23,8 +23,11 @@ export function parseHookLine(line: string): HookEvent | null {
23
23
  return null;
24
24
  }
25
25
 
26
+ const agent = parsed['agent'];
27
+
26
28
  return {
27
29
  atcId: toSessionID(parsed['atcId']),
30
+ ...(typeof agent === 'string' && agent !== '' ? { agent } : {}),
28
31
  event: parsed['event'],
29
32
  payload: parsed['payload'],
30
33
  };
@@ -46,6 +46,11 @@ export class SessionRuntime {
46
46
  // later dropped may be restarting, so it keeps the inbox open.
47
47
  tapAttached = false;
48
48
 
49
+ // Whether a hook line carrying the session's agent arrived since the
50
+ // terminal last booted. Once one has, the session's own hooks are known
51
+ // to carry it, and a line without one comes from another harness.
52
+ hasAgentHookLines = false;
53
+
49
54
  // Returns the boot-scoped state to how a fresh terminal starts, at the
50
55
  // dims it boots with: no SessionStart yet and no tap since. Every path
51
56
  // that boots a new terminal for an existing session runs it, so a revived
@@ -54,6 +59,7 @@ export class SessionRuntime {
54
59
  this.dims = dims;
55
60
  this.startedAt = null;
56
61
  this.tapAttached = false;
62
+ this.hasAgentHookLines = false;
57
63
  }
58
64
 
59
65
  dispose(): void {
@@ -0,0 +1,52 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ /**
6
+ * What a provisioning call is about to touch: the imps under their actual
7
+ * names, as the target's runtime namespace builds them, and every secret
8
+ * the binding grants them.
9
+ */
10
+ export interface BrokerActivation {
11
+ readonly impNames: readonly string[];
12
+ readonly secrets: readonly string[];
13
+ }
14
+
15
+ /**
16
+ * The gate before atc grants or relies on a brokered credential. It reads
17
+ * impd's features, then the token's identity, and writes nothing, so a
18
+ * refusal leaves impd as it was. It rejects unless impd has both grantable
19
+ * tokens and secret rebinds, the token may manage each imp and reaches no
20
+ * imp outside the namespace whose imp names start with the prefix, and the
21
+ * token may grant every bound secret.
22
+ * Whether each secret's rules match the binding is a separate comparison.
23
+ */
24
+ export async function verifyBrokerAuthority(
25
+ port: Pick<ImpPort, 'readFeatures' | 'readIdentity'>,
26
+ activation: BrokerActivation,
27
+ impPrefix: string,
28
+ ): Promise<void> {
29
+ const features = await port.readFeatures();
30
+
31
+ if (!features.grantableTokens || !features.secretRebind) {
32
+ throw new BrokerAuthorityError(
33
+ 'auth_impd_too_old',
34
+ 'impd lacks grantable tokens or secret rebinds; it must be 0.27.0 or later',
35
+ { grantableTokens: features.grantableTokens, secretRebind: features.secretRebind },
36
+ );
37
+ }
38
+
39
+ const identity = await port.readIdentity();
40
+
41
+ verifyTokenImpAuthority(identity, activation.impNames, impPrefix);
42
+
43
+ const missing = activation.secrets.filter((secret) => !identity.grantable.includes(secret));
44
+
45
+ if (missing.length > 0) {
46
+ throw new BrokerAuthorityError(
47
+ 'auth_secret_not_grantable',
48
+ `impd token ${identity.name} cannot grant ${missing.join(', ')}`,
49
+ { token: identity.name, grantable: identity.grantable, missing },
50
+ );
51
+ }
52
+ }
@@ -0,0 +1,44 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort, ImpView } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ // The imp a binding recorded when atc made it.
6
+ export interface RecordedImp {
7
+ readonly name: string;
8
+ readonly id: string;
9
+ }
10
+
11
+ /**
12
+ * The check before atc destroys a session's imp or revokes its grants.
13
+ * It asks that the token may manage the recorded imp, under imp patterns
14
+ * that stay inside the namespace whose imp names start with the prefix,
15
+ * and that the imp under that
16
+ * name is still the one recorded. It never asks that a secret's rules
17
+ * still match or that every grant is still in place, so a rotated, rebound
18
+ * or deleted secret never stops atc removing access. Resolves to the imp,
19
+ * or to null when impd confirms no imp holds the name, which needs no
20
+ * cleanup; an imp with another id rejects.
21
+ */
22
+ export async function verifyCleanupAuthority(
23
+ port: Pick<ImpPort, 'readIdentity' | 'readImp'>,
24
+ recorded: RecordedImp,
25
+ impPrefix: string,
26
+ ): Promise<ImpView | null> {
27
+ const identity = await port.readIdentity();
28
+
29
+ // Checked first, so impd's answer for the name comes from a token that
30
+ // can see the imp, and a missing imp means it is gone.
31
+ verifyTokenImpAuthority(identity, [recorded.name], impPrefix);
32
+
33
+ const imp = await port.readImp(recorded.name);
34
+
35
+ if (imp !== null && imp.id !== recorded.id) {
36
+ throw new BrokerAuthorityError(
37
+ 'auth_runtime_mismatch',
38
+ `imp ${recorded.name} is no longer the imp atc made`,
39
+ { imp: recorded.name, recordedID: recorded.id, actualID: imp.id },
40
+ );
41
+ }
42
+
43
+ return imp;
44
+ }
@@ -0,0 +1,74 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpIdentity } from './imp-port';
3
+ import { isImpNameAllowed } from './is-imp-name-allowed';
4
+
5
+ /**
6
+ * Rejects unless the token may manage every named imp and reaches no imp
7
+ * outside atc's namespace: scope `manage`, imp patterns rather than none,
8
+ * and each pattern contained in the namespace prefix, so the literal text
9
+ * before its first `*`, or the whole pattern when it has none, starts with
10
+ * the prefix. A token with any pattern beyond the prefix is refused
11
+ * outright, however few names that pattern reaches, never used in place of
12
+ * a contained one. Each name must then match a pattern. The prefix is the
13
+ * literal start of every imp name the target's runtime namespace builds,
14
+ * and the names are the imps the call touches under those built names.
15
+ */
16
+ export function verifyTokenImpAuthority(
17
+ identity: ImpIdentity,
18
+ impNames: readonly string[],
19
+ impPrefix: string,
20
+ ): void {
21
+ assertImpPrefix(impPrefix);
22
+
23
+ if (identity.scope !== 'manage') {
24
+ throw new BrokerAuthorityError(
25
+ 'auth_token_scope',
26
+ `impd token ${identity.name} cannot manage imps`,
27
+ {
28
+ token: identity.name,
29
+ scope: identity.scope,
30
+ },
31
+ );
32
+ }
33
+
34
+ const patterns = identity.imps;
35
+
36
+ if (patterns === null || !patterns.every((pattern) => isPatternContained(pattern, impPrefix))) {
37
+ throw new BrokerAuthorityError(
38
+ 'auth_token_too_broad',
39
+ `impd token ${identity.name} can reach imps outside atc's namespace, whose imp names start with ${impPrefix}`,
40
+ {
41
+ token: identity.name,
42
+ imps: patterns,
43
+ offending:
44
+ patterns === null
45
+ ? null
46
+ : patterns.filter((pattern) => !isPatternContained(pattern, impPrefix)),
47
+ },
48
+ );
49
+ }
50
+
51
+ const outside = impNames.filter((name) => !isImpNameAllowed(patterns, name));
52
+
53
+ if (outside.length > 0) {
54
+ throw new BrokerAuthorityError(
55
+ 'auth_imp_out_of_scope',
56
+ `impd token ${identity.name} cannot manage ${outside.join(', ')}`,
57
+ { token: identity.name, imps: patterns, outside },
58
+ );
59
+ }
60
+ }
61
+
62
+ // An empty prefix would contain every pattern, `*` included.
63
+ function assertImpPrefix(impPrefix: string): void {
64
+ if (impPrefix === '') {
65
+ throw new Error('the imp name prefix of a runtime namespace must not be empty');
66
+ }
67
+ }
68
+
69
+ // Every name a pattern matches starts with the text before its first `*`.
70
+ function isPatternContained(pattern: string, impPrefix: string): boolean {
71
+ const [literal = ''] = pattern.split('*');
72
+
73
+ return literal.startsWith(impPrefix);
74
+ }
@@ -4,10 +4,12 @@ import { isRecord, sendReport } from './shared/report';
4
4
  /**
5
5
  * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
6
6
  * (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
7
- * event name to the atc unix socket. Always exits 0 so it never blocks the
8
- * session it reports on.
7
+ * event name to the atc unix socket, with the agent id the hook command
8
+ * gave it, so the daemon can tell a nested harness's report from the
9
+ * session's own. Always exits 0 so it never blocks the session it reports
10
+ * on.
9
11
  */
10
- export async function runHookReport(): Promise<void> {
12
+ export async function runHookReport(agent: string): Promise<void> {
11
13
  const sock = process.env['ATC_SOCKET'];
12
14
  const atcId = process.env['ATC_SESSION_ID'];
13
15
 
@@ -26,7 +28,7 @@ export async function runHookReport(): Promise<void> {
26
28
 
27
29
  const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
28
30
  const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
29
- const line = `${JSON.stringify({ atcId, event, payload })}\n`;
31
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event, payload })}\n`;
30
32
 
31
33
  await sendReport(sock, line, 2000);
32
34
  }
@@ -1,11 +1,15 @@
1
+ import type { AgentID } from '../shared/agent-id';
1
2
  import type { SessionID } from '../shared/session-id';
2
3
 
3
4
  /**
4
- * One line a hook reporter sends: the atc session it reports on, the agent's
5
- * hook event name, and the hook's payload as the agent gave it.
5
+ * One line a hook reporter sends: the atc session it reports on, the agent
6
+ * whose hook command sent it, the agent's hook event name, and the hook's
7
+ * payload as the agent gave it. The agent is absent on a line from a hook
8
+ * command that carries none.
6
9
  */
7
10
  export interface HookEvent {
8
11
  atcId: SessionID;
12
+ agent?: AgentID;
9
13
  event: string;
10
14
  payload: Record<string, unknown>;
11
15
  }
package/src/statusline.ts CHANGED
@@ -8,10 +8,11 @@ import { isRecord, sendReport } from './shared/report';
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
10
10
  * user's own statusline (from ~/.claude/settings.json), appends the atc fleet
11
- * segment, and heartbeats the session id back to the atc socket. Always
12
- * exits 0 so it never breaks the session it renders for.
11
+ * segment, and heartbeats the session id back to the atc socket, with the
12
+ * agent id the command gave it. Always exits 0 so it never breaks the
13
+ * session it renders for.
13
14
  */
14
- export async function runStatusline(): Promise<void> {
15
+ export async function runStatusline(agent: string): Promise<void> {
15
16
  const raw = await new Response(Bun.stdin.stream()).text();
16
17
 
17
18
  const sock = process.env['ATC_SOCKET'];
@@ -28,7 +29,7 @@ export async function runStatusline(): Promise<void> {
28
29
  }
29
30
  } catch {}
30
31
 
31
- const line = `${JSON.stringify({ atcId, event: 'Statusline', payload })}\n`;
32
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event: 'Statusline', payload })}\n`;
32
33
 
33
34
  await sendReport(sock, line, 500);
34
35
  }
@@ -128,7 +129,8 @@ async function readOwnSegment(sock: string): Promise<string> {
128
129
  }
129
130
 
130
131
  // A user statusline that is atc's own injected command would chain into
131
- // itself; the injected command always ends with the bare subcommand.
132
+ // itself; the injected command ends with the bare subcommand, or with the
133
+ // subcommand and its agent flag.
132
134
  function isSelfCommand(cmd: string): boolean {
133
- return cmd.includes('statusline.ts') || cmd.trimEnd().endsWith(' statusline');
135
+ return cmd.includes('statusline.ts') || /\sstatusline(?:\s+--agent\s.*)?$/u.test(cmd);
134
136
  }