@kevin5251984/guild 0.2.19 → 0.2.21

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": "@kevin5251984/guild",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "dev": "tsx src/cli.ts",
33
33
  "start": "node ./bin/guildd.mjs",
34
- "build": "tsc -p tsconfig.json",
34
+ "build": "node --check ./bin/guildd.mjs",
35
35
  "test": "tsx --test test/*.test.ts",
36
36
  "prepack": "node ./scripts/vendor-protocol.mjs prepack",
37
37
  "postpack": "node ./scripts/vendor-protocol.mjs postpack"
package/src/cli.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { execFile } from "node:child_process";
3
2
  import { startGuildDaemon } from "./start.ts";
4
3
  import {
@@ -6,6 +5,7 @@ import {
6
5
  parseGuildCli,
7
6
  shouldOpenBrowser,
8
7
  } from "./cli-args.ts";
8
+ import { guildVersion } from "./version.ts";
9
9
 
10
10
  const opts = parseGuildCli(process.argv);
11
11
  if (opts.error) {
@@ -15,10 +15,7 @@ if (opts.error) {
15
15
  } else if (opts.help) {
16
16
  process.stdout.write(guildCliHelp());
17
17
  } else if (opts.version) {
18
- const pkg = JSON.parse(
19
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
20
- ) as { version: string };
21
- process.stdout.write(`${pkg.version}\n`);
18
+ process.stdout.write(`${guildVersion()}\n`);
22
19
  } else {
23
20
  if (opts.port !== undefined) process.env.GUILD_PORT = String(opts.port);
24
21
  const started = startGuildDaemon();
package/src/db.ts CHANGED
@@ -25,7 +25,14 @@ import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
25
25
  export const GUILD_DB_FILE = "guild.sqlite";
26
26
  /** Per-room hot window in SQLite. Older rows spill to rooms/<id>/trajectory.jsonl. */
27
27
  export const TRAJECTORY_HOT_CAP = 1000;
28
- const SCHEMA_VERSION = "2";
28
+ /** Bump when this guildd writes a shape an older guildd cannot read. */
29
+ export const SCHEMA_VERSION = "2";
30
+
31
+ /** Numeric view of a `meta.schema` value. Missing/garbage = 0 (migrate). */
32
+ function schemaVersionOf(raw: unknown): number {
33
+ const value = Number(typeof raw === "string" ? raw.trim() : raw);
34
+ return Number.isFinite(value) ? value : 0;
35
+ }
29
36
  const WAREHOUSE_TAIL = 1024 * 1024;
30
37
 
31
38
  const SCHEMA = `
@@ -281,7 +288,24 @@ export class GuildDb {
281
288
  constructor(readonly path: string) {
282
289
  mkdirSync(dirname(path), { recursive: true });
283
290
  this.sqlite = new DatabaseSync(path, { timeout: 5000 });
284
- this.sqlite.exec(SCHEMA);
291
+ try {
292
+ this.sqlite.exec(SCHEMA);
293
+ // Refuse to open a DB written by a newer guildd: the ALTERs below and the
294
+ // version upsert would silently migrate the file back down.
295
+ const row = this.sqlite
296
+ .prepare("SELECT value FROM meta WHERE key = 'schema'")
297
+ .get() as { value?: unknown } | undefined;
298
+ const stored = schemaVersionOf(row?.value);
299
+ const current = schemaVersionOf(SCHEMA_VERSION);
300
+ if (stored > current) {
301
+ throw new Error(
302
+ `guild.sqlite schema ${stored} is newer than this guildd (${current}); upgrade guildd`,
303
+ );
304
+ }
305
+ } catch (error) {
306
+ this.close();
307
+ throw error;
308
+ }
285
309
  try {
286
310
  this.sqlite.exec("ALTER TABLE messages ADD COLUMN steer_bot_id TEXT");
287
311
  } catch {
@@ -302,13 +326,22 @@ export class GuildDb {
302
326
  } catch {
303
327
  /* column already exists on fresh schema */
304
328
  }
305
- this.sqlite.prepare(
306
- "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
307
- ).run(SCHEMA_VERSION);
329
+ try {
330
+ this.sqlite.prepare(
331
+ "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
332
+ ).run(SCHEMA_VERSION);
333
+ } catch (error) {
334
+ this.close();
335
+ throw error;
336
+ }
308
337
  }
309
338
 
310
339
  close(): void {
311
- this.sqlite.close();
340
+ try {
341
+ this.sqlite.close();
342
+ } catch {
343
+ /* already closed */
344
+ }
312
345
  }
313
346
 
314
347
  importLegacyFiles(dataDir: string): void {
package/src/generate.ts CHANGED
@@ -350,9 +350,9 @@ When work belongs to someone else, put @handle at the start of a line with a wri
350
350
  - Done when
351
351
  - Constraints / out of scope
352
352
  - Files or evidence
353
- Each line-start @handle on this quest starts that seat. A markdown numbered list that names a teammate (1. @design) also starts them, even if the handle is wrapped in backticks. Mentions that are only commentary in a sentence do not dispatch.
354
- Do not @all unless the human did. Do not recruit extra people; the human staffs the roster (max ${CHANNEL_ROSTER_CAP} on a quest).
355
- You may @handle any staffed teammate whose job is the next step, even if the human only named you this turn. That is how the hall continues. Do not dump the same work on every seat. If two seats must run in order, only @ the seat that can start now a numbered list that names later seats starts them this turn too. Do not write a plan and stop.
353
+ Each line-start @handle of a bot already on this quest starts that seat this turn. A markdown numbered list item that leads with a teammate (1. @design) also starts them, even if the handle is wrapped in backticks. Mentions that are only commentary in a sentence do not dispatch.
354
+ Do not @all unless the human did. Do not recruit extra people; the human staffs the roster with 加入 (max ${CHANNEL_ROSTER_CAP} on a quest). @handle never adds a seat.
355
+ You may @handle any staffed teammate whose job is the next step, even if the human only named you this turn. That is how the hall continues. Do not @handle a bot who is not on this quest. Do not dump the same work on every seat. If two seats must run in order, only @ the seat that can start now. Later seats stay in prose (四席完成後由 @infra, 通過後 @marketing, 最後 @infra) — those do not start this turn. After the first wave reports, @handle the next seat with a spec. Do not write a plan and stop.
356
356
  Stay quiet: no status theater, no "I'll start now." Speak when you finish, block, or need a decision. Money, sends, and destructive actions wait for the human.
357
357
 
358
358
  Harness this turn (Memory → Plan → Skills → Act):
package/src/handlers.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  listGuildMcp,
44
44
  listHostMcp,
45
45
  listMcpToolRefs,
46
+ publicMcpServer,
46
47
  removeGuildMcp,
47
48
  upsertGuildMcp,
48
49
  } from "./mcp.ts";
@@ -92,12 +93,13 @@ export function listLibrary(store: GuildStore, kind: LibraryKind) {
92
93
  return store.listLibrary(kind);
93
94
  }
94
95
 
96
+ /** HTTP surfaces never carry `launch.env` values; see `publicMcpServer`. */
95
97
  export function listMcpServers(store: GuildStore) {
96
- return listGuildMcp(store.dataDir);
98
+ return listGuildMcp(store.dataDir).map(publicMcpServer);
97
99
  }
98
100
 
99
101
  export function listHostMcpServers() {
100
- return listHostMcp();
102
+ return listHostMcp().map(publicMcpServer);
101
103
  }
102
104
 
103
105
  export function createMcpServer(
@@ -112,13 +114,15 @@ export function createMcpServer(
112
114
  },
113
115
  ) {
114
116
  try {
115
- return upsertGuildMcp(store.dataDir, input.name, {
116
- command: input.command || "",
117
- args: input.args || [],
118
- env: input.env,
119
- cwd: input.cwd,
120
- url: input.url,
121
- });
117
+ return publicMcpServer(
118
+ upsertGuildMcp(store.dataDir, input.name, {
119
+ command: input.command || "",
120
+ args: input.args || [],
121
+ env: input.env,
122
+ cwd: input.cwd,
123
+ url: input.url,
124
+ }),
125
+ );
122
126
  } catch (error) {
123
127
  throw new StoreError(
124
128
  400,
@@ -129,7 +133,7 @@ export function createMcpServer(
129
133
 
130
134
  export function importMcpServer(store: GuildStore, hostId: string) {
131
135
  try {
132
- return importHostMcp(store.dataDir, hostId);
136
+ return publicMcpServer(importHostMcp(store.dataDir, hostId));
133
137
  } catch (error) {
134
138
  throw new StoreError(
135
139
  404,
@@ -518,6 +522,7 @@ export function workspace(store: GuildStore) {
518
522
  startedAt: turn.startedAt || "",
519
523
  thinking: turn.thinking,
520
524
  steps: turn.steps,
525
+ ...(turn.paused ? { paused: true } : {}),
521
526
  },
522
527
  ];
523
528
  });
@@ -753,32 +758,14 @@ function askedText(
753
758
  return `附件:\n${legend}\n\n${asked}`;
754
759
  }
755
760
 
756
- /** @handle of a bot not in this channel adds them, then they can reply. */
761
+ /** Roster is staffed in the members panel. @handle never pulls a new seat. */
757
762
  export function inviteMentionedBots(
758
763
  store: GuildStore,
759
764
  roomId: string,
760
- userMessage: { author?: string; body: string; mentions?: string[] },
765
+ _userMessage?: { author?: string; body: string; mentions?: string[] },
761
766
  ): string[] {
762
767
  const room = store.getRoom(roomId);
763
- if (!room) return [];
764
- if (room.kind !== "channel") return room.memberIds;
765
- if (isBroadcastMention(userMessage.body)) return room.memberIds;
766
- const ids = messageMentionIds(
767
- {
768
- author: userMessage.author || "you",
769
- body: userMessage.body,
770
- mentions: userMessage.mentions,
771
- },
772
- store.listBots(),
773
- );
774
- let memberIds = room.memberIds;
775
- for (const id of ids) {
776
- if (memberIds.includes(id)) continue;
777
- if (!store.getBot(id)) continue;
778
- store.addMember(roomId, id);
779
- memberIds = [...memberIds, id];
780
- }
781
- return memberIds;
768
+ return room?.memberIds ?? [];
782
769
  }
783
770
 
784
771
  export function channelMarkdownForRoom(
@@ -1010,6 +997,7 @@ function publicLiveTurn(live: LiveTurn): LiveTurn {
1010
997
  thinking: live.thinking,
1011
998
  steps: live.steps,
1012
999
  startedAt: live.startedAt,
1000
+ ...(live.paused ? { paused: true } : {}),
1013
1001
  };
1014
1002
  }
1015
1003
 
@@ -1099,6 +1087,84 @@ export function abortLiveTurn(
1099
1087
  return { ok: true };
1100
1088
  }
1101
1089
 
1090
+ export function pauseLiveTurn(
1091
+ store: GuildStore,
1092
+ roomId: string,
1093
+ botId?: string,
1094
+ ) {
1095
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1096
+ const live = botId
1097
+ ? store.getLiveBotTurn(roomId, botId)
1098
+ : store.getLiveTurn(roomId);
1099
+ if (live?.paused) return { ok: true, paused: true as const };
1100
+ const had = store.pauseTurn(roomId, botId);
1101
+ if (!live && !had) throw new StoreError(409, "no live turn");
1102
+ return { ok: true, paused: true as const };
1103
+ }
1104
+
1105
+ function resumeSteer(live: LiveTurn): string {
1106
+ const lines = [
1107
+ "Paused mid-turn so the user could switch models. Continue from here. Do not redo finished tools unless you need a different result.",
1108
+ ];
1109
+ const thinking = (live.thinking || "").trim();
1110
+ if (thinking) lines.push(`Thinking so far:\n${thinking.slice(0, 6000)}`);
1111
+ const tools = (live.traces || []).filter((tr) => tr.name && tr.name !== "think");
1112
+ if (tools.length) {
1113
+ lines.push(
1114
+ "Tools already run:\n" +
1115
+ tools
1116
+ .slice(-20)
1117
+ .map((tr) => {
1118
+ const bit = String(tr.text || "")
1119
+ .replace(/\s+/g, " ")
1120
+ .trim()
1121
+ .slice(0, 400);
1122
+ return `- ${tr.name}${bit ? `: ${bit}` : ""}`;
1123
+ })
1124
+ .join("\n"),
1125
+ );
1126
+ }
1127
+ return lines.join("\n\n");
1128
+ }
1129
+
1130
+ export async function continueLiveTurn(
1131
+ store: GuildStore,
1132
+ roomId: string,
1133
+ botId: string,
1134
+ env: NodeJS.ProcessEnv = process.env,
1135
+ extras: HandlerExtras = {},
1136
+ ) {
1137
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1138
+ const id = botId.trim();
1139
+ if (!id) throw new StoreError(400, "botId is required");
1140
+ const live = store.getLiveBotTurn(roomId, id);
1141
+ if (!live?.paused) throw new StoreError(409, "no paused turn");
1142
+ const messages = store.listMessages(roomId);
1143
+ let userIndex = messages.length - 1;
1144
+ while (userIndex >= 0 && messages[userIndex].author !== "you") userIndex -= 1;
1145
+ if (userIndex < 0) throw new StoreError(400, "no user message to continue");
1146
+ const userMessage = messages[userIndex];
1147
+ const asked = live.asked?.trim() || userMessage.body.trim();
1148
+ if (!asked) throw new StoreError(409, "nothing to continue");
1149
+ const history = messages.slice(0, userIndex).map(toHistoryItem);
1150
+ const parent = parentMessage(messages.slice(0, userIndex), userMessage.replyTo);
1151
+ const room = store.getRoom(roomId);
1152
+ store.setLiveTurn(roomId, { ...live, paused: false });
1153
+ store.pushSteer(roomId, resumeSteer(live), id);
1154
+ const replies = await generateReplies(
1155
+ store,
1156
+ roomId,
1157
+ room?.memberIds ?? [id],
1158
+ { ...userMessage, body: asked },
1159
+ history,
1160
+ id,
1161
+ env,
1162
+ parent,
1163
+ extras,
1164
+ );
1165
+ return { replies };
1166
+ }
1167
+
1102
1168
  function isAbortError(err: unknown): boolean {
1103
1169
  return Boolean(
1104
1170
  err &&
@@ -1180,17 +1246,22 @@ async function generateReplies(
1180
1246
  turnAsked: string,
1181
1247
  turnHistory: HistoryItem[],
1182
1248
  ) => {
1183
- if (!memberIds.includes(botId) && !onlyBotId) return;
1249
+ if (!memberIds.includes(botId)) return;
1184
1250
  if (signal.aborted) return;
1185
1251
  const prev = store.getLiveBotTurn(roomId, botId);
1252
+ if (!prev || prev.paused) return;
1186
1253
  const startedAt = prev?.startedAt || new Date().toISOString();
1187
1254
  store.dropLastFailedReply(roomId, botId);
1188
1255
  store.setLiveTurn(roomId, {
1189
1256
  botId,
1190
1257
  thinking: prev?.thinking || "",
1191
1258
  steps: prev?.steps || [],
1259
+ traces: prev?.traces,
1260
+ asked: turnAsked,
1192
1261
  startedAt,
1262
+ paused: false,
1193
1263
  });
1264
+ const botSignal = store.armBotTurn(roomId, botId, signal);
1194
1265
  let generated;
1195
1266
  try {
1196
1267
  generated = await (extras.turn ?? chatReply)({
@@ -1203,10 +1274,11 @@ async function generateReplies(
1203
1274
  userMessage.body,
1204
1275
  ),
1205
1276
  env,
1206
- signal,
1277
+ signal: botSignal,
1207
1278
  mcpTools,
1208
1279
  onProgress: (update) => {
1209
1280
  const prev = store.getLiveBotTurn(roomId, botId);
1281
+ if (prev?.paused) return;
1210
1282
  const next = toLiveTurn(botId, update);
1211
1283
  const handoff = (prev?.steps || []).find((step) => step.name === "handoff");
1212
1284
  const pendingSteers = store.peekSteers(roomId, botId).map((text) => ({
@@ -1223,14 +1295,16 @@ async function generateReplies(
1223
1295
  );
1224
1296
  store.setLiveTurn(roomId, {
1225
1297
  ...next,
1298
+ asked: prev?.asked || turnAsked,
1226
1299
  startedAt: prev?.startedAt || startedAt,
1300
+ paused: false,
1227
1301
  steps: [...(handoff ? [handoff] : []), ...keptSteer, ...rest].slice(0, 5),
1228
1302
  });
1229
1303
  },
1230
1304
  pullSteers: () => store.drainSteers(roomId, botId),
1231
1305
  });
1232
1306
  } catch (err) {
1233
- if (isAbortError(err) || signal.aborted) return;
1307
+ if (isAbortError(err) || botSignal.aborted || signal.aborted) return;
1234
1308
  throw err;
1235
1309
  }
1236
1310
  const usage = { ...(generated.usage || {}), startedAt };
@@ -1418,13 +1492,17 @@ function plantLiveTurns(
1418
1492
  const startedAt = new Date().toISOString();
1419
1493
  for (const botId of botIds) {
1420
1494
  if (!botId) continue;
1421
- if (!memberIds.includes(botId) && !onlyBotId) continue;
1495
+ if (!memberIds.includes(botId)) continue;
1422
1496
  store.dropLastFailedReply(roomId, botId);
1497
+ const prev = store.getLiveBotTurn(roomId, botId);
1423
1498
  store.setLiveTurn(roomId, {
1424
1499
  botId,
1425
- thinking: "",
1426
- steps: [],
1427
- startedAt,
1500
+ thinking: prev?.thinking || "",
1501
+ steps: prev?.steps || [],
1502
+ traces: prev?.traces,
1503
+ asked: prev?.asked,
1504
+ startedAt: prev?.startedAt || startedAt,
1505
+ paused: false,
1428
1506
  });
1429
1507
  }
1430
1508
  return startedAt;
@@ -1482,18 +1560,13 @@ function hasExplicitSummon(
1482
1560
  }
1483
1561
 
1484
1562
  function includeFollowBot(
1485
- store: GuildStore,
1486
- roomId: string,
1563
+ _store: GuildStore,
1564
+ _roomId: string,
1487
1565
  memberIds: string[],
1488
1566
  follow?: string,
1489
1567
  ): string[] {
1490
1568
  if (!follow || memberIds.includes(follow)) return memberIds;
1491
- try {
1492
- store.addMember(roomId, follow);
1493
- return [...memberIds, follow];
1494
- } catch {
1495
- return memberIds;
1496
- }
1569
+ return memberIds;
1497
1570
  }
1498
1571
 
1499
1572
  function inviteAssignee(
@@ -1504,12 +1577,10 @@ function inviteAssignee(
1504
1577
  const room = store.getRoom(roomId);
1505
1578
  if (!room) return [];
1506
1579
  if (room.kind !== "channel") return room.memberIds;
1507
- if (room.memberIds.includes(assigneeId)) return room.memberIds;
1508
1580
  if (!store.getBot(assigneeId)) {
1509
1581
  throw new StoreError(400, "assignee does not exist");
1510
1582
  }
1511
- store.addMember(roomId, assigneeId);
1512
- return [...room.memberIds, assigneeId];
1583
+ return room.memberIds;
1513
1584
  }
1514
1585
 
1515
1586
  export async function postUserMessage(
@@ -1730,4 +1801,9 @@ export async function retryMessage(
1730
1801
 
1731
1802
  export { StoreError, localGenerate };
1732
1803
 
1733
- export { publicModels, mergeModelsFile, refreshOpenCodeFreeCatalog } from "./llm.ts";
1804
+ export {
1805
+ publicModels,
1806
+ mergeModelsFile,
1807
+ refreshOpenCodeFreeCatalog,
1808
+ refreshReasoningCatalog,
1809
+ } from "./llm.ts";
package/src/harness.ts CHANGED
@@ -171,9 +171,10 @@ export function gateTool(
171
171
  };
172
172
  }
173
173
 
174
+ // No explicit workspace means the guild checkout, never all of $HOME.
174
175
  const workspace = input.workspace?.trim()
175
176
  ? resolveToolPath(input.workspace)
176
- : HOME;
177
+ : defaultWorkspace();
177
178
 
178
179
  if (name.startsWith("mcp__")) {
179
180
  return {
@@ -182,9 +183,20 @@ export function gateTool(
182
183
  };
183
184
  }
184
185
 
186
+ if (name === "read" || name === "list") {
187
+ const raw = typeof args.path === "string" ? args.path : "";
188
+ if (name === "list" && !raw.trim()) return null;
189
+ const target = resolveToolPath(raw, workspace);
190
+ if (!pathInsideWorkspace(target, workspace)) {
191
+ return {
192
+ text: `sandbox=workspace_write refused ${name} outside workspace: ${target}`,
193
+ isError: true,
194
+ };
195
+ }
196
+ return null;
197
+ }
198
+
185
199
  if (
186
- name === "read" ||
187
- name === "list" ||
188
200
  name === "skill" ||
189
201
  name === "spawn" ||
190
202
  name === "read_spawn"
@@ -224,7 +236,11 @@ export function gateTool(
224
236
  };
225
237
  }
226
238
 
227
- return null;
239
+ // browser (CDP into the user's Chrome profile) and anything unnamed: refuse.
240
+ return {
241
+ text: `sandbox=workspace_write refused ${name}; use full_access`,
242
+ isError: true,
243
+ };
228
244
  }
229
245
 
230
246
  export type LoopCall = {
@@ -3,10 +3,11 @@ import {
3
3
  existsSync,
4
4
  readdirSync,
5
5
  readFileSync,
6
+ realpathSync,
6
7
  statSync,
7
8
  } from "node:fs";
8
9
  import { homedir } from "node:os";
9
- import { dirname, join, resolve } from "node:path";
10
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
10
11
  import { promisify } from "node:util";
11
12
  import { StoreError } from "./store.ts";
12
13
 
@@ -31,6 +32,117 @@ function resolveUserPath(input: string): string {
31
32
  return resolve(HOME, trimmed);
32
33
  }
33
34
 
35
+ /** Guild data dirs: `~/.guild` plus an explicit `GUILD_HOME` if set. */
36
+ function guildHomes(): string[] {
37
+ const homes = [join(HOME, ".guild")];
38
+ const extra = process.env.GUILD_HOME?.trim();
39
+ if (extra) homes.push(resolveUserPath(extra));
40
+ return homes;
41
+ }
42
+
43
+ const SSH_DIR = join(HOME, ".ssh");
44
+ /** Files whose *contents* are credentials, wherever the guild home lives. */
45
+ const SECRET_GUILD_FILES = new Set(["oauth.json", "models.json", "mcp.json"]);
46
+ /** Credential dotfiles that live directly in `$HOME`. */
47
+ const SECRET_HOME_FILES = new Set([
48
+ ".claude.json",
49
+ ".netrc",
50
+ "_netrc",
51
+ ".npmrc",
52
+ ".yarnrc.yml",
53
+ ".git-credentials",
54
+ ".env",
55
+ ".env.local",
56
+ ".env.production",
57
+ ".pgpass",
58
+ ".pypirc",
59
+ ".my.cnf",
60
+ "credentials.json",
61
+ ]);
62
+ /** `$HOME` folders that are credential stores: the folder and everything below. */
63
+ const SECRET_HOME_DIRS = [
64
+ ".aws",
65
+ ".docker",
66
+ ".gnupg",
67
+ ".claude",
68
+ ".codex",
69
+ ".cursor",
70
+ ".kube",
71
+ ".azure",
72
+ join(".config", "gcloud"),
73
+ join(".config", "gh"),
74
+ join("Library", "Keychains"),
75
+ ].map((relative) => join(HOME, relative));
76
+ const SSH_KEY_NAME = /^(id_rsa|id_dsa|id_ecdsa|id_ed25519|id_xmss)$/i;
77
+ const SECRET_SUFFIXES = [
78
+ ".pem",
79
+ ".p12",
80
+ ".pfx",
81
+ ".key",
82
+ ".p8",
83
+ ".jks",
84
+ ".keystore",
85
+ ];
86
+
87
+ function under(path: string, dir: string): boolean {
88
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
89
+ return path === dir || path.startsWith(prefix);
90
+ }
91
+
92
+ /** Longest existing ancestor resolved through symlinks, tail re-joined. */
93
+ function canonicalPath(target: string): string {
94
+ let abs = resolve(target);
95
+ const tail: string[] = [];
96
+ for (;;) {
97
+ try {
98
+ const real = realpathSync(abs);
99
+ return tail.length ? resolve(real, ...tail) : real;
100
+ } catch {
101
+ const parent = dirname(abs);
102
+ if (parent === abs) return tail.length ? resolve(abs, ...tail) : abs;
103
+ tail.unshift(basename(abs));
104
+ abs = parent;
105
+ }
106
+ }
107
+ }
108
+
109
+ function isSecretPath(abs: string): boolean {
110
+ const name = basename(abs);
111
+ const lower = name.toLowerCase();
112
+ const publicKey = lower.endsWith(".pub");
113
+ for (const home of guildHomes()) {
114
+ if (under(abs, join(home, "browser-profile"))) return true;
115
+ if (under(abs, home) && SECRET_GUILD_FILES.has(lower)) return true;
116
+ }
117
+ if (dirname(abs) === HOME && SECRET_HOME_FILES.has(lower)) return true;
118
+ if (name === ".env" || name.startsWith(".env.")) return true;
119
+ if (lower === "credentials.json" || lower === "service-account.json") {
120
+ return true;
121
+ }
122
+ if (SECRET_HOME_DIRS.some((dir) => under(abs, dir))) return true;
123
+ if (under(abs, SSH_DIR) && abs !== SSH_DIR && !publicKey) return true;
124
+ if (SSH_KEY_NAME.test(name)) return true;
125
+ if (!publicKey && SECRET_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * The attach picker browses `$HOME` on purpose, so /host/* is not confined to a
133
+ * workspace. Secrets still have to stay shut: OAuth tokens, model keys, the MCP
134
+ * store, the cloned browser profile, private keys, and the usual credential
135
+ * dotfiles / folders (`.aws`, `.claude`, `.codex`, `.npmrc`, `.netrc`, …).
136
+ * This is a denylist over the picker, not a chroot: non-secret `$HOME` and
137
+ * system files such as `/etc/passwd` stay readable.
138
+ */
139
+ export function assertHostPathAllowed(target: string): void {
140
+ const abs = isAbsolute(target) ? resolve(target) : resolveUserPath(target);
141
+ if (isSecretPath(canonicalPath(abs))) {
142
+ throw new StoreError(403, "host path refused");
143
+ }
144
+ }
145
+
34
146
  function parentOf(path: string): string | null {
35
147
  const parent = dirname(path);
36
148
  if (parent === path) return null;
@@ -52,6 +164,7 @@ export function hostList(rawPath: string): {
52
164
  } {
53
165
  try {
54
166
  const target = resolveUserPath(rawPath);
167
+ assertHostPathAllowed(target);
55
168
  const st = statSync(target);
56
169
  if (!st.isDirectory()) throw new StoreError(400, "not a directory");
57
170
  const entries = readdirSync(target, { withFileTypes: true })
@@ -94,6 +207,7 @@ export function hostRead(rawPath: string): {
94
207
  } {
95
208
  try {
96
209
  const target = resolveUserPath(rawPath);
210
+ assertHostPathAllowed(target);
97
211
  const st = statSync(target);
98
212
  if (!st.isFile()) throw new StoreError(400, "not a file");
99
213
  const raw = readFileSync(target);
@@ -146,6 +260,7 @@ function walkTree(
146
260
  export function hostTree(rawPath: string): { path: string; text: string } {
147
261
  try {
148
262
  const target = resolveUserPath(rawPath);
263
+ assertHostPathAllowed(target);
149
264
  const st = statSync(target);
150
265
  if (!st.isDirectory()) throw new StoreError(400, "not a directory");
151
266
  const lines = [target];
@@ -158,6 +273,25 @@ export function hostTree(rawPath: string): { path: string; text: string } {
158
273
  }
159
274
  }
160
275
 
276
+ /**
277
+ * Git runs inside the user's tree: ignore their global/system config (hooks,
278
+ * fsmonitor, credential helpers) and never prompt on a TTY-less daemon.
279
+ */
280
+ const GIT_GUARD = [
281
+ "-c",
282
+ "core.fsmonitor=false",
283
+ "-c",
284
+ "core.untrackedCache=false",
285
+ "--no-optional-locks",
286
+ ] as const;
287
+
288
+ const GIT_ENV: NodeJS.ProcessEnv = {
289
+ ...process.env,
290
+ GIT_CONFIG_GLOBAL: "/dev/null",
291
+ GIT_CONFIG_NOSYSTEM: "1",
292
+ GIT_TERMINAL_PROMPT: "0",
293
+ };
294
+
161
295
  function findGitRoot(start: string): string | null {
162
296
  let dir = start;
163
297
  for (let i = 0; i < 12; i += 1) {
@@ -176,14 +310,25 @@ export async function hostGit(rawPath: string): Promise<{
176
310
  }> {
177
311
  try {
178
312
  const start = resolveUserPath(rawPath);
313
+ assertHostPathAllowed(start);
179
314
  const base = statSync(start).isDirectory() ? start : dirname(start);
180
315
  const root = findGitRoot(base);
181
316
  if (!root) throw new StoreError(404, "not a git repository");
182
- const opts = { cwd: root, timeout: 8_000, maxBuffer: GIT_CAP * 2 };
183
- const status = await execFileAsync("git", ["status", "-sb"], opts);
317
+ assertHostPathAllowed(root);
318
+ const opts = {
319
+ cwd: root,
320
+ timeout: 8_000,
321
+ maxBuffer: GIT_CAP * 2,
322
+ env: GIT_ENV,
323
+ };
324
+ const status = await execFileAsync("git", [...GIT_GUARD, "status", "-sb"], opts);
184
325
  let diff = "";
185
326
  try {
186
- const out = await execFileAsync("git", ["diff", "--stat", "HEAD"], opts);
327
+ const out = await execFileAsync(
328
+ "git",
329
+ [...GIT_GUARD, "diff", "--stat", "HEAD"],
330
+ opts,
331
+ );
187
332
  diff = String(out.stdout || "");
188
333
  } catch {
189
334
  diff = "";