@botbuddy/cli 1.30.2 → 1.31.1

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": "@botbuddy/cli",
3
- "version": "1.30.2",
3
+ "version": "1.31.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,150 @@
1
+ // BOT-1650 — the durable cleanup marker and the guarded cache clear that pair
2
+ // with the unified agent-state store (agent-state.mjs). Kept in a small module of
3
+ // their own so both the bootstrap bridge and the Codex bridge can share them
4
+ // without re-importing the whole credential resolver.
5
+ import { mkdir, open, rename, unlink, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { AGENT_KEY_RE } from "./agent-key.mjs";
9
+ import { agentStatePath, readAgentState, withStateLock, worktreeRoot } from "./agent-state.mjs";
10
+ import { restrictWindowsAcl, restrictWindowsAclDir, sessionPathIsConfidential } from "./agent-state-fs.mjs";
11
+
12
+ // BOT-1650 (Codex round-32 P2): bind the confidentiality check to the EXACT bytes
13
+ // parsed. A separate stat()+readFile() lets a hostile local writer swap the
14
+ // owner-only marker between the check and the read (the CLI state lock does not
15
+ // coordinate foreign processes). Open ONCE, verify the open handle's mode — on
16
+ // POSIX the fd is pinned to that inode, so a later rename cannot change what we
17
+ // read — then read from the SAME handle. Returns the entries array, or null when
18
+ // the marker is absent, non-confidential, or corrupt. (Windows ACLs are checked by
19
+ // path via icacls, so there the guarantee is the owner-only ACL applied at publish;
20
+ // the handle-bound atomicity is POSIX.)
21
+ async function readConfidentialMarker(file, { platform } = {}) {
22
+ let handle;
23
+ try {
24
+ handle = await open(file, "r");
25
+ } catch { return null; }
26
+ try {
27
+ const { mode } = await handle.stat();
28
+ if (!(await sessionPathIsConfidential(file, mode, platform ? { platform } : {}))) return null;
29
+ const parsed = JSON.parse(await handle.readFile("utf8"));
30
+ return Array.isArray(parsed) ? parsed : null;
31
+ } catch {
32
+ return null;
33
+ } finally {
34
+ await handle.close().catch(() => {});
35
+ }
36
+ }
37
+
38
+ // BOT-1650 (Codex round-29 P2): the marker records session ids + generations the
39
+ // next bootstrap TRUSTS and retires with the operator's durable credential, so its
40
+ // integrity matters. Publish it with the same confidentiality as agent-state.json:
41
+ // exclusive 0600 temp, an explicit owner-only Windows ACL (0600 is a no-op for
42
+ // ACLs and the marker lives under an arbitrary worktree), then an atomic rename. A
43
+ // failure surfaces (the temp is unlinked) — a permissive marker is never published.
44
+ async function publishMarker(file, data, { platform = process.platform, restrictAcl = restrictWindowsAcl } = {}) {
45
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(temporary, `${JSON.stringify(data)}\n`, { mode: 0o600, flag: "wx" });
48
+ if (platform === "win32") await restrictAcl(temporary);
49
+ await rename(temporary, file);
50
+ } catch (error) {
51
+ await unlink(temporary).catch(() => {});
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ // BOT-1650 (Codex round-24): a marker for sessions the server admitted but that
57
+ // this harness could not retire (a compound outage failed both the cached close
58
+ // and the new session's retirement). It records only the session id + generation
59
+ // — NOT a bearer — because the durable owner/MCP credential retires a session by
60
+ // id, so the marker is not a secret. A later bootstrap drains it, preventing an
61
+ // orphaned session from lingering and blocking with agent_session_conflict.
62
+ export const AGENT_CLEANUP_STATE = join(".botbuddy", "agent-cleanup.json");
63
+
64
+ export async function recordPendingCleanup(entry, { cwd = process.cwd(), platform, restrictAcl, restrictAclDir = restrictWindowsAclDir } = {}) {
65
+ if (!entry?.session_id) return;
66
+ const file = join(await worktreeRoot(cwd), AGENT_CLEANUP_STATE);
67
+ const directory = dirname(file);
68
+ await mkdir(directory, { recursive: true, mode: 0o700 });
69
+ // BOT-1650 (Codex round-34 P2): protect the CONTAINING directory on Windows
70
+ // (owner-only, no inheritance) so no other local account can create or replace
71
+ // marker entries — the by-path ACL check on the marker is only trustworthy when
72
+ // the directory itself cannot be swapped. POSIX 0o700 already does this.
73
+ if ((platform ?? process.platform) === "win32") await restrictAclDir(directory);
74
+ // BOT-1650 (Codex round-31/32 P2): only merge a PRE-EXISTING marker when it is
75
+ // still confidential (owner-only), verified against the OPEN handle we read from
76
+ // (see readConfidentialMarker). A marker another account could substitute in a
77
+ // shared worktree is discarded, not merged, so its attacker-supplied session ids
78
+ // are never republished under our owner-only ACL and later retired with the
79
+ // operator's durable credential.
80
+ const existing = (await readConfidentialMarker(file, { platform })) ?? [];
81
+ const record = {
82
+ session_id: entry.session_id,
83
+ ...(entry.session_token_generation ? { session_token_generation: entry.session_token_generation } : {}),
84
+ };
85
+ const merged = [...existing.filter((e) => e?.session_id !== record.session_id), record];
86
+ await publishMarker(file, merged, { platform, restrictAcl });
87
+ }
88
+
89
+ // Retry retiring previously-orphaned sessions. `retire(entry)` returns true when
90
+ // the remote session is confirmed retired; confirmed entries drop from the marker
91
+ // and the marker file is removed once empty.
92
+ export async function drainPendingCleanup({ cwd = process.cwd(), retire, platform, restrictAcl } = {}) {
93
+ const file = join(await worktreeRoot(cwd), AGENT_CLEANUP_STATE);
94
+ // BOT-1650 (Codex round-30/32 P2): every entry here is retired with the operator's
95
+ // durable credential, so a marker another account could substitute must not be
96
+ // trusted. Verify confidentiality against the OPEN handle we read from (see
97
+ // readConfidentialMarker) so a forged marker can neither retire the victim's
98
+ // sessions nor discard pending orphan cleanup — and a swap between check and read
99
+ // cannot slip past. A null result (absent, non-confidential, or corrupt) keeps the
100
+ // marker untouched for a later drain.
101
+ const entries = await readConfidentialMarker(file, { platform });
102
+ if (entries === null) return;
103
+ if (entries.length === 0) {
104
+ await unlink(file).catch(() => {});
105
+ return;
106
+ }
107
+ const remaining = [];
108
+ for (const entry of entries) {
109
+ let done = false;
110
+ try { done = await retire(entry); } catch { done = false; }
111
+ if (!done) remaining.push(entry);
112
+ }
113
+ if (remaining.length === entries.length) return; // nothing retired; keep marker
114
+ if (remaining.length === 0) {
115
+ await unlink(file).catch(() => {});
116
+ return;
117
+ }
118
+ try {
119
+ await publishMarker(file, remaining, { platform, restrictAcl });
120
+ } catch { /* leave the existing marker in place; a later drain retries */ }
121
+ }
122
+
123
+ /**
124
+ * Remove only the cached credential belonging to this exact closed incarnation.
125
+ *
126
+ * Guarded: it unlinks only when the CURRENT on-disk state still matches this
127
+ * session id AND token. A close→reopen intentionally reuses `session_id` but mints
128
+ * a new bearer, so an old close can never delete a successor's cache.
129
+ *
130
+ * BOT-1650 (Codex round-27 P2): the read-compare-unlink must be serialized with
131
+ * cache writers on the SAME `<agent-state>.lock` writes take, or a concurrent
132
+ * bootstrap can rename a successor cache between this read and the unlink — and the
133
+ * unlink then deletes the successor, stranding an active remote holder with no
134
+ * local credential. Hold the lock by default. The bootstrap transaction already
135
+ * holds it (the file lock is not re-entrant), so that caller passes `lock: false`.
136
+ */
137
+ export async function clearAgentSessionIfMatches(sessionId, { cwd = process.cwd(), sessionToken, lock = true } = {}) {
138
+ if (typeof sessionToken !== "string" || !AGENT_KEY_RE.test(sessionToken)) {
139
+ throw new Error("clearing a harness session requires its expected session token");
140
+ }
141
+ const compareAndUnlink = async () => {
142
+ const state = await readAgentState(cwd);
143
+ if (state && state.session_id === sessionId && state.agent_session_token === sessionToken) {
144
+ await unlink(await agentStatePath(cwd)).catch(() => {});
145
+ return true;
146
+ }
147
+ return false;
148
+ };
149
+ return lock ? withStateLock(cwd, {}, compareAndUnlink) : compareAndUnlink();
150
+ }
package/src/agent-key.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // BOT-1649 — the per-session agent token the CLI presents to the relay for
2
2
  // `botbuddy wait`/`run`/`test`/`pw`.
3
3
  //
4
- // register_agent mints this token (bound to the work-graph session it returns)
4
+ // bootstrap_agent_session mints this token (bound to the work-graph session it returns)
5
5
  // and the relay derives agent, tenant, and the arming session from it, so a
6
6
  // session that exports it needs no --profile/--session-id/--token.
7
7
  //
Binary file
@@ -0,0 +1,90 @@
1
+ // BOT-1650 — confidentiality hardening for the per-worktree agent-session cache,
2
+ // shared by agent-state.mjs (read/write) so the plaintext tier-3 bearer is never
3
+ // exposed to another account. The mechanism differs by platform:
4
+ // • POSIX: the 0o077 "group/other must be empty" bitmask on the file mode.
5
+ // • Windows: mode 0o600 does NOT restrict Windows ACLs, and the cache lives under
6
+ // the ARBITRARY worktree (not a guaranteed user-profile dir), so a permissive
7
+ // inherited ACL could expose the bearer to another account. Verify an owner-only
8
+ // ACL via `icacls` and fail closed if a broad principal is granted or the ACL
9
+ // cannot be read.
10
+ import os from "node:os";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // BOT-1650 (Codex round-19/20 P1): validate that EVERY granted principal is on an
17
+ // explicit allow-list, comparing the FULL account identity (DOMAIN\user), not a
18
+ // short-name suffix — a suffix match on `\me` would accept both CONTOSO\me and
19
+ // EVIL\me on a domain host. The only principals that may hold the bearer are the
20
+ // current user and the OS-privileged SYSTEM / Administrators. Any other grantee —
21
+ // or an unparseable entry — fails closed.
22
+ export function windowsAclIsOwnerOnly(aclText, { self = windowsSelfIdentity(), file } = {}) {
23
+ if (typeof aclText !== "string" || !aclText.trim()) return false;
24
+ const owner = self.trim().toLowerCase();
25
+ const permitted = (principal) => {
26
+ const p = principal.trim().toLowerCase();
27
+ return p === "nt authority\\system"
28
+ || p === "builtin\\administrators"
29
+ || p === owner;
30
+ };
31
+ // icacls prints the target path before the FIRST ACE, then one "<principal>:(<perms>)"
32
+ // per line. BOT-1650 (Codex round-27 P2): both the worktree path AND a Windows
33
+ // principal ("NT AUTHORITY\SYSTEM") can contain spaces, so a whitespace heuristic
34
+ // truncates a spaced path INTO the first principal and wrongly rejects an
35
+ // owner-only ACL. Strip the EXACT known path when the caller supplies it; only
36
+ // fall back to a leading space-free drive path otherwise (the confidential-read
37
+ // caller always supplies `file`, so a spaced worktree path takes the exact strip).
38
+ let text = aclText;
39
+ if (file && text.includes(file)) text = text.replace(file, "");
40
+ else text = text.replace(/^[A-Za-z]:\\\S*\s+(?=\S)/, "");
41
+ // BOT-1650 (Codex round-28 P1): native icacls appends a "Successfully processed N
42
+ // files; Failed processing M files" summary after the ACEs. Validate ONLY lines
43
+ // carrying a "<principal>:(<perms>)" ACE — the footer and blank lines are not
44
+ // ACEs and must be skipped, not treated as an unparseable grant. Still fail
45
+ // closed when there is NO ACE at all or an ACE's principal/perms is malformed.
46
+ const aces = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.includes(":("));
47
+ if (aces.length === 0) return false;
48
+ for (const line of aces) {
49
+ const at = line.indexOf(":(");
50
+ const perms = line.slice(at + 1);
51
+ if (!/^(?:\([A-Za-z,]+\))+$/.test(perms)) return false;
52
+ if (!permitted(line.slice(0, at))) return false;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ // The current account as icacls records it: DOMAIN\username (USERDOMAIN is the
58
+ // computer name for a local account, the AD domain for a domain account). Falls
59
+ // back to the bare username only when the domain is unknown.
60
+ export function windowsSelfIdentity(env = process.env) {
61
+ const user = env.USERNAME || os.userInfo().username;
62
+ const domain = env.USERDOMAIN;
63
+ return domain ? `${domain}\\${user}` : user;
64
+ }
65
+
66
+ const readWindowsAcl = (file) => execFileAsync("icacls", [file], { encoding: "utf8" }).then((r) => r.stdout);
67
+
68
+ export async function sessionPathIsConfidential(file, mode, { platform = process.platform, readAcl = readWindowsAcl } = {}) {
69
+ if (platform !== "win32") return (mode & 0o077) === 0;
70
+ try {
71
+ return windowsAclIsOwnerOnly(await readAcl(file), { file });
72
+ } catch {
73
+ return false; // an unreadable ACL is not provably confidential — fail closed.
74
+ }
75
+ }
76
+
77
+ // Apply an owner-only ACL (strip inheritance, grant only the current user) so a
78
+ // freshly written cache is confidential on Windows regardless of the worktree's
79
+ // inherited permissions. A failure must surface, never persist a readable bearer.
80
+ export const restrictWindowsAcl = (file, run = (args) => execFileAsync("icacls", args)) =>
81
+ run([file, "/inheritance:r", "/grant:r", `${windowsSelfIdentity()}:(F)`]);
82
+
83
+ // BOT-1650 (Codex round-34 P2): the Windows ACL check is inherently by path (icacls
84
+ // cannot bind to an open handle), so a foreign account could swap a file between
85
+ // open and the icacls check. Close that at the DIRECTORY: restrict `.botbuddy` to
86
+ // the current user with no inheritance, and mark it (OI)(CI) so new children
87
+ // inherit the owner-only ACE — no other local account can then create or replace
88
+ // entries inside it, which is what makes the by-path check trustworthy on Windows.
89
+ export const restrictWindowsAclDir = (dir, run = (args) => execFileAsync("icacls", args)) =>
90
+ run([dir, "/inheritance:r", "/grant:r", `${windowsSelfIdentity()}:(OI)(CI)(F)`]);
@@ -1,17 +1,20 @@
1
1
  // BOT-1649 — a per-worktree, gitignored cache for the short-lived agent session.
2
2
  // It deliberately holds a session token (mode 0600), never a durable client key.
3
- import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join, parse } from "node:path";
5
+ import { dirname, join, parse, resolve } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { AGENT_KEY_RE } from "./agent-key.mjs";
8
8
  import { acquireStackLock } from "./stack-file-lock.mjs";
9
+ import { restrictWindowsAcl, restrictWindowsAclDir, sessionPathIsConfidential } from "./agent-state-fs.mjs";
10
+
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
9
12
 
10
13
  // BOT-1649 (Codex round-4 P2): every mutation of the cache (self-heal mint, an
11
14
  // SSE keepalive expiry touch, logout's clear) must be serialized on ONE lock —
12
15
  // the same `<agent-state>.lock` selfHealAgentSession takes — so a delayed touch
13
16
  // can never rename a stale credential back over a newer mint or a logout delete.
14
- async function withStateLock(cwd, { timeoutMs } = {}, fn) {
17
+ export async function withStateLock(cwd, { timeoutMs } = {}, fn) {
15
18
  const lockPath = `${await agentStatePath(cwd)}.lock`;
16
19
  await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
17
20
  const lease = await acquireStackLock(lockPath, timeoutMs ? { timeoutMs } : {});
@@ -45,38 +48,102 @@ export async function agentStatePath(cwd = process.cwd()) {
45
48
  return join(await findAgentStateRoot(cwd), AGENT_STATE_PATH);
46
49
  }
47
50
 
51
+ /** BOT-1650: the nearest enclosing git worktree root, never crossing above a
52
+ * repository binding. A standalone caller falls back to its cwd. Used by the
53
+ * bootstrap bridge and the Codex bridge to anchor the per-worktree cache.
54
+ *
55
+ * BOT-1650 (Codex round-32 P2): the result is canonicalized through realpath so
56
+ * the SAME checkout reached via a symlink or a case alias resolves to one path.
57
+ * The Codex bridge compares these strings to detect an existing bootstrapped
58
+ * thread; without canonicalization an aliased path bypasses that gate and boots a
59
+ * second thread against the same physical cache, whose replacement flow then
60
+ * revokes the first thread's session and overwrites its cache. */
61
+ export async function worktreeRoot(cwd = process.cwd()) {
62
+ const canonical = async (p) => { try { return await realpath(p); } catch { return resolve(p); } };
63
+ let dir = resolve(cwd);
64
+ const root = parse(dir).root;
65
+ while (true) {
66
+ try {
67
+ await stat(join(dir, ".git"));
68
+ return await canonical(dir);
69
+ } catch (err) {
70
+ if (err?.code !== "ENOENT") throw err;
71
+ }
72
+ if (dir === root) return await canonical(cwd);
73
+ dir = dirname(dir);
74
+ }
75
+ }
76
+
48
77
  function validState(value) {
49
- return Boolean(value) && typeof value === "object" && value.schema_version === 1
78
+ if (!(Boolean(value) && typeof value === "object" && value.schema_version === 1
50
79
  && typeof value.agent_id === "string" && value.agent_id.length > 0
51
80
  && typeof value.agent_session_token === "string" && AGENT_KEY_RE.test(value.agent_session_token)
52
81
  && (typeof value.session_id === "string" || value.session_id === null)
53
- && typeof value.tenant === "string" && value.tenant.length > 0
54
- && typeof value.host === "string" && value.host.length > 0
55
- && typeof value.worktree === "string" && value.worktree.length > 0
56
- && typeof value.minted_at === "string" && !Number.isNaN(Date.parse(value.minted_at))
57
- && typeof value.expires_at === "string" && !Number.isNaN(Date.parse(value.expires_at));
82
+ && typeof value.expires_at === "string" && !Number.isNaN(Date.parse(value.expires_at)))) {
83
+ return false;
84
+ }
85
+ // BOT-1650: tenant/host/worktree/minted_at are always present on a self-heal
86
+ // minted state, but OPTIONAL on a bootstrap-written one — bootstrap_agent_session
87
+ // does not echo the tenant (the relay derives it from the token). When present
88
+ // they must still be well formed.
89
+ if (value.tenant !== undefined && !(typeof value.tenant === "string" && value.tenant.length > 0)) return false;
90
+ if (value.host !== undefined && !(typeof value.host === "string" && value.host.length > 0)) return false;
91
+ if (value.worktree !== undefined && !(typeof value.worktree === "string" && value.worktree.length > 0)) return false;
92
+ if (value.minted_at !== undefined && !(typeof value.minted_at === "string" && !Number.isNaN(Date.parse(value.minted_at)))) return false;
93
+ // BOT-1650: the nonsecret incarnation fence for close/heartbeat idempotency.
94
+ if (value.session_token_generation !== undefined
95
+ && !(typeof value.session_token_generation === "string" && UUID_RE.test(value.session_token_generation))) return false;
96
+ return true;
58
97
  }
59
98
 
60
99
  /** Never throw malformed cache contents into credential resolution. */
61
100
  export async function readAgentState(cwd = process.cwd()) {
101
+ const path = await agentStatePath(cwd);
102
+ let handle;
62
103
  try {
63
- const value = JSON.parse(await readFile(await agentStatePath(cwd), "utf8"));
104
+ handle = await open(path, "r");
105
+ } catch {
106
+ return null; // absent or unreadable
107
+ }
108
+ try {
109
+ // BOT-1650: never load a plaintext bearer from a group/other-readable file
110
+ // (POSIX) or a broadly-ACL'd file (Windows). Fail closed exactly like a missing
111
+ // cache. BOT-1650 (Codex round-33 P2): verify the OPEN handle's mode and read
112
+ // from the SAME handle so a hostile local writer cannot swap agent-state.json
113
+ // between the check and the read (TOCTOU) — on POSIX the fd is pinned to the
114
+ // inode. Windows ACLs are a by-path icacls check backed by the owner-only ACL
115
+ // applied at write.
116
+ const { mode } = await handle.stat();
117
+ if (!(await sessionPathIsConfidential(path, mode))) return null;
118
+ const value = JSON.parse(await handle.readFile("utf8"));
64
119
  return validState(value) ? value : null;
65
120
  } catch {
66
121
  return null;
122
+ } finally {
123
+ await handle.close().catch(() => {});
67
124
  }
68
125
  }
69
126
 
70
127
  /** Atomically replace cache content and ensure the destination remains owner-only. */
71
- export async function writeAgentState(cwd, state) {
128
+ export async function writeAgentState(cwd, state, { platform = process.platform, restrictAcl = restrictWindowsAcl, restrictAclDir = restrictWindowsAclDir } = {}) {
72
129
  if (!validState(state)) throw new Error("agent state is missing required fields");
73
130
  const path = await agentStatePath(cwd);
74
131
  const directory = dirname(path);
75
132
  await mkdir(directory, { recursive: true, mode: 0o700 });
133
+ // BOT-1650 (Codex round-34 P2): on Windows the by-path ACL check can be raced by
134
+ // swapping a file in the directory, so protect the CONTAINING directory itself —
135
+ // owner-only, no inheritance — so no other local account can create or replace
136
+ // entries in .botbuddy. On POSIX the 0o700 mode already does this.
137
+ if (platform === "win32") await restrictAclDir(directory);
76
138
  const temp = join(directory, `.${AGENT_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`);
77
139
  try {
78
140
  await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
79
141
  await chmod(temp, 0o600);
142
+ // BOT-1650: mode 0o600 is a no-op for Windows ACLs and the cache lives under an
143
+ // arbitrary worktree, so explicitly restrict the temp to the current user before
144
+ // publishing it. A failure surfaces (the finally unlinks the temp) — the bearer
145
+ // is never published under an inherited, possibly-shared ACL.
146
+ if (platform === "win32") await restrictAcl(temp);
80
147
  await rename(temp, path);
81
148
  await chmod(path, 0o600);
82
149
  } finally {