@kevin5251984/guild 0.2.18 → 0.2.20

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/src/harness.ts CHANGED
@@ -156,16 +156,25 @@ export function gateTool(
156
156
  if (sandbox === "full_access") return null;
157
157
 
158
158
  if (sandbox === "read_only") {
159
- if (name === "read" || name === "list" || name === "skill") return null;
159
+ if (
160
+ name === "read" ||
161
+ name === "list" ||
162
+ name === "skill" ||
163
+ name === "spawn" ||
164
+ name === "read_spawn"
165
+ ) {
166
+ return null;
167
+ }
160
168
  return {
161
169
  text: `sandbox=read_only refused ${name}`,
162
170
  isError: true,
163
171
  };
164
172
  }
165
173
 
174
+ // No explicit workspace means the guild checkout, never all of $HOME.
166
175
  const workspace = input.workspace?.trim()
167
176
  ? resolveToolPath(input.workspace)
168
- : HOME;
177
+ : defaultWorkspace();
169
178
 
170
179
  if (name.startsWith("mcp__")) {
171
180
  return {
@@ -174,7 +183,24 @@ export function gateTool(
174
183
  };
175
184
  }
176
185
 
177
- if (name === "read" || name === "list" || name === "skill" || name === "spawn") {
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
+
199
+ if (
200
+ name === "skill" ||
201
+ name === "spawn" ||
202
+ name === "read_spawn"
203
+ ) {
178
204
  return null;
179
205
  }
180
206
 
@@ -210,7 +236,11 @@ export function gateTool(
210
236
  };
211
237
  }
212
238
 
213
- 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
+ };
214
244
  }
215
245
 
216
246
  export type LoopCall = {
@@ -296,18 +326,17 @@ export async function runAgentLoop(input: {
296
326
  if (traces.length) return { text: emptyAfterTools, traces, thinking };
297
327
  return input.nullIfNoTraces ? null : { text: emptyAfterTools, traces, thinking };
298
328
  }
299
- const outcomes: ToolOutcome[] = [];
300
- for (const call of asked.calls) {
301
- outcomes.push(
302
- await executeToolTraced(
329
+ const outcomes = await Promise.all(
330
+ asked.calls.map((call) =>
331
+ executeToolTraced(
303
332
  call.name,
304
333
  call.args,
305
334
  input.toolCtx,
306
335
  traces,
307
336
  thinking,
308
337
  ),
309
- );
310
- }
338
+ ),
339
+ );
311
340
  input.onTools?.(asked.calls, outcomes);
312
341
  }
313
342
  }
@@ -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 = "";
package/src/image-gen.ts CHANGED
@@ -218,7 +218,7 @@ export async function generateImage(input: {
218
218
  aspectRatio?: string;
219
219
  dataDir?: string;
220
220
  env?: NodeJS.ProcessEnv;
221
- }): Promise<{ text: string; isError: boolean }> {
221
+ }): Promise<{ text: string; isError: boolean; publicPath?: string }> {
222
222
  const prompt = String(input.prompt || "").trim();
223
223
  if (!prompt) return { text: "prompt is required", isError: true };
224
224
  const aspect = String(input.aspectRatio || "").trim();
@@ -252,6 +252,7 @@ export async function generateImage(input: {
252
252
  `markdown: ![${prompt.slice(0, 80)}](${saved.publicPath})`,
253
253
  ].join("\n"),
254
254
  isError: false,
255
+ publicPath: saved.publicPath,
255
256
  };
256
257
  }
257
258
  errors.push(`${route.model} @ ${route.url}: ${hit.error}`);