@gethmy/harness 1.2.1 → 1.3.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.
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from "vitest";
2
1
  import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ rmSync,
5
+ symlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
11
+ import {
12
+ CONFINED_READ_TOOLS,
13
+ CONFINED_WRITE_TOOLS,
3
14
  confineToRepo,
4
15
  decideConfinedTool,
5
16
  isInsideTree,
@@ -111,6 +122,77 @@ describe("decideConfinedTool", () => {
111
122
  });
112
123
  });
113
124
 
125
+ it("denies a Glob PATTERN that escapes, not only a Glob path", () => {
126
+ // `Glob.path` is optional, so a pattern-only call used to fall through the
127
+ // path loop and be allowed — while node-glob honours an absolute pattern
128
+ // and a leading `../` regardless of cwd. That enumerated the operator's
129
+ // credential directory into the branch being pushed (security review of
130
+ // #1015), and it is exactly the "pinned, therefore looks complete" gap the
131
+ // `**/*.ts` case above created.
132
+ expect(decide("Glob", { pattern: "/Users/dev/.claude/**" }).behavior).toBe(
133
+ "deny",
134
+ );
135
+ expect(decide("Glob", { pattern: "../../**/*.json" }).behavior).toBe(
136
+ "deny",
137
+ );
138
+ expect(decide("Glob", { pattern: "~/.ssh/*" }).behavior).toBe("deny");
139
+ // Grep's `glob` filter is the same shape and gets the same treatment.
140
+ expect(decide("Grep", { pattern: "x", glob: "/etc/**" }).behavior).toBe(
141
+ "deny",
142
+ );
143
+ });
144
+
145
+ it("denies the glob SPELLINGS of `..`, not only the literal one", () => {
146
+ // The first version rejected an absolute pattern and a literal `..`
147
+ // segment, and the security review walked past it: brace expansion and
148
+ // character classes spell the same traversal while reading as ordinary
149
+ // segments. A probe using `{..,x}/` returned 43 files from `~/.claude`.
150
+ // The rule is now an allowlist of syntax — `* ** ? /` and literals — so a
151
+ // NEW spelling cannot reopen it, because the syntax that would carry it is
152
+ // refused outright.
153
+ for (const pattern of [
154
+ "{..,x}/y",
155
+ "[.][.]/*",
156
+ "{..,x}/{..,x}/Users/me/.claude/*",
157
+ "@(..)/x",
158
+ "!(a)/../x",
159
+ "+(.)./x",
160
+ ]) {
161
+ expect(
162
+ decide("Glob", { pattern }).behavior,
163
+ `Glob must refuse ${pattern}`,
164
+ ).toBe("deny");
165
+ }
166
+ });
167
+
168
+ it("still allows the plain glob syntax a repair actually needs", () => {
169
+ for (const pattern of [
170
+ "**/*.ts",
171
+ "src/**/*.test.ts",
172
+ "src/a?.ts",
173
+ "*.md",
174
+ ]) {
175
+ expect(decide("Glob", { pattern }), pattern).toEqual({
176
+ behavior: "allow",
177
+ });
178
+ }
179
+ });
180
+
181
+ it("does not mistake an ordinary name for traversal", () => {
182
+ // `..` must match a SEGMENT. A file called `a..b` or `..rc` is not an
183
+ // escape, and denying it would make the guard read as broken.
184
+ expect(decide("Glob", { pattern: "src/**/a..b.ts" })).toEqual({
185
+ behavior: "allow",
186
+ });
187
+ expect(decide("Glob", { pattern: "**/..eslintrc" })).toEqual({
188
+ behavior: "allow",
189
+ });
190
+ // `Grep.pattern` is a REGEX, never a path — `..` there is "any two chars".
191
+ expect(decide("Grep", { pattern: "foo..bar" })).toEqual({
192
+ behavior: "allow",
193
+ });
194
+ });
195
+
114
196
  it("ignores a non-string or empty path rather than crashing", () => {
115
197
  expect(decide("Read", { file_path: 42 })).toEqual({ behavior: "allow" });
116
198
  expect(decide("Read", { file_path: "" })).toEqual({ behavior: "allow" });
@@ -142,3 +224,244 @@ describe("confineToRepo", () => {
142
224
  ).resolves.toMatchObject({ behavior: "deny" });
143
225
  });
144
226
  });
227
+
228
+ // --- the write surface (#1015) ---
229
+
230
+ describe("write mode", () => {
231
+ const w = (tool: string, input: Record<string, unknown>) =>
232
+ decideConfinedTool(ROOT, tool, input, "write");
233
+
234
+ it("confines every write tool to the tree", () => {
235
+ for (const tool of ["Write", "Edit", "MultiEdit"]) {
236
+ expect(w(tool, { file_path: `${ROOT}/src/a.ts` })).toEqual({
237
+ behavior: "allow",
238
+ });
239
+ expect(w(tool, { file_path: "/etc/cron.d/x" }).behavior).toBe("deny");
240
+ expect(w(tool, { file_path: `${ROOT}/../elsewhere/x` }).behavior).toBe(
241
+ "deny",
242
+ );
243
+ }
244
+ expect(w("NotebookEdit", { notebook_path: `${ROOT}/n.ipynb` })).toEqual({
245
+ behavior: "allow",
246
+ });
247
+ expect(w("NotebookEdit", { notebook_path: "/tmp/n.ipynb" }).behavior).toBe(
248
+ "deny",
249
+ );
250
+ });
251
+
252
+ it("says WRITE in the refusal, so a denied write is not read as a denied read", () => {
253
+ const d = w("Write", { file_path: "/etc/passwd" });
254
+ expect(d).toMatchObject({ behavior: "deny" });
255
+ if (d.behavior === "deny") expect(d.message).toContain("write");
256
+ });
257
+
258
+ it("still denies the shell and the network tools", () => {
259
+ // Write mode widens the FILE surface and nothing else. `Bash` is not a
260
+ // path-carrying tool that got confined; it is absent from the map, which is
261
+ // a denial.
262
+ for (const tool of ["Bash", "WebFetch", "WebSearch", "Task", "Agent"]) {
263
+ expect(w(tool, { command: "id", url: "https://x" }).behavior).toBe(
264
+ "deny",
265
+ );
266
+ }
267
+ });
268
+
269
+ it("does NOT let read mode write — the default is the read-only surface", () => {
270
+ // The sizing preflight depends on this: it calls `confineToRepo(cwd)` with
271
+ // no mode, and must not acquire `Write` because this module learned how to
272
+ // confine one.
273
+ expect(
274
+ decideConfinedTool(ROOT, "Write", { file_path: `${ROOT}/a` }),
275
+ ).toMatchObject({ behavior: "deny" });
276
+ expect(CONFINED_READ_TOOLS).not.toContain("Write");
277
+ expect(CONFINED_WRITE_TOOLS).toEqual(
278
+ expect.arrayContaining(["Read", "Glob", "Grep", "Write", "Edit"]),
279
+ );
280
+ });
281
+ });
282
+
283
+ describe("git metadata", () => {
284
+ // The host-RCE the previous comment declared impossible. A linked worktree's
285
+ // `.git` is a regular FILE, so nothing traverses THROUGH it — and the file is
286
+ // still inside the tree, writable, and holds the pointer to git's config.
287
+ // Rewrite it to `gitdir: ./g`, write `[core] fsmonitor = ./payload.sh` there,
288
+ // and the diff gate's own `git status` runs the payload on the daemon host.
289
+ // No shell, no network tool, no subagent, no sandbox involved.
290
+ const w = (tool: string, input: Record<string, unknown>) =>
291
+ decideConfinedTool(ROOT, tool, input, "write");
292
+
293
+ it("denies WRITING the worktree's .git pointer", () => {
294
+ expect(w("Write", { file_path: `${ROOT}/.git` }).behavior).toBe("deny");
295
+ expect(w("Write", { file_path: ".git" }).behavior).toBe("deny");
296
+ });
297
+
298
+ it("denies anything UNDER a .git directory, at any depth", () => {
299
+ for (const p of [
300
+ `${ROOT}/.git/config`,
301
+ `${ROOT}/.git/hooks/pre-push`,
302
+ `${ROOT}/sub/.git/config`,
303
+ "sub/.git/config",
304
+ ]) {
305
+ expect(w("Write", { file_path: p }).behavior, p).toBe("deny");
306
+ expect(decide("Read", { file_path: p }).behavior, p).toBe("deny");
307
+ }
308
+ });
309
+
310
+ it("denies it in ANY case — the daemon's filesystem is case-insensitive", () => {
311
+ // macOS and Windows treat `.GIT` and `.git` as the same file, so a
312
+ // case-sensitive check let a write to `.GIT` through every guard and land
313
+ // on the real gitdir pointer. Proven to reach host code execution in
314
+ // review round 5 of #1015, via a `filter` driver in the redirected config.
315
+ for (const name of [".GIT", ".Git", ".gIt"]) {
316
+ expect(w("Write", { file_path: `${ROOT}/${name}` }).behavior, name).toBe(
317
+ "deny",
318
+ );
319
+ expect(
320
+ w("Write", { file_path: `${ROOT}/sub/${name}/config` }).behavior,
321
+ name,
322
+ ).toBe("deny");
323
+ }
324
+ });
325
+
326
+ it("leaves ordinary dot-git-PREFIXED files alone", () => {
327
+ // Matched as a segment, so these are unaffected — denying them would make
328
+ // the guard read as broken and invite someone to loosen it.
329
+ for (const p of [".gitignore", "src/.gitkeep", ".github/CODEOWNERS"]) {
330
+ expect(w("Write", { file_path: `${ROOT}/${p}` }), p).toEqual({
331
+ behavior: "allow",
332
+ });
333
+ }
334
+ });
335
+ });
336
+
337
+ describe("symlinks", () => {
338
+ // The escape a purely lexical check cannot see: a link INSIDE the tree whose
339
+ // target is outside it. Every path under `<tree>/escape` is inside the tree by
340
+ // string comparison and outside it in fact — and the repair spawn can both
341
+ // create such a link's contents and write through one that a branch already
342
+ // carries.
343
+ let tree: string;
344
+ let outside: string;
345
+
346
+ beforeAll(() => {
347
+ const base = mkdtempSync(join(tmpdir(), "confine-"));
348
+ tree = join(base, "repo");
349
+ outside = join(base, "secrets");
350
+ mkdirSync(tree);
351
+ mkdirSync(outside);
352
+ writeFileSync(join(outside, "credentials.json"), "{}");
353
+ symlinkSync(outside, join(tree, "escape"));
354
+ symlinkSync(join(outside, "credentials.json"), join(tree, "creds.json"));
355
+ });
356
+
357
+ afterAll(() => {
358
+ rmSync(tree, { recursive: true, force: true });
359
+ rmSync(outside, { recursive: true, force: true });
360
+ });
361
+
362
+ it("denies a read THROUGH a symlink that points out of the tree", () => {
363
+ expect(
364
+ decideConfinedTool(tree, "Read", {
365
+ file_path: join(tree, "creds.json"),
366
+ }).behavior,
367
+ ).toBe("deny");
368
+ expect(
369
+ decideConfinedTool(tree, "Read", {
370
+ file_path: join(tree, "escape", "credentials.json"),
371
+ }).behavior,
372
+ ).toBe("deny");
373
+ });
374
+
375
+ it("denies a WRITE through one too — the exfiltration direction", () => {
376
+ expect(
377
+ decideConfinedTool(
378
+ tree,
379
+ "Write",
380
+ { file_path: join(tree, "escape", "planted.sh") },
381
+ "write",
382
+ ).behavior,
383
+ ).toBe("deny");
384
+ });
385
+
386
+ it("still allows an ordinary path, and one that does not exist yet", () => {
387
+ // A `Write` creating a new file is the ordinary case, and it must not be
388
+ // denied merely because `realpath` cannot resolve a file that is not there.
389
+ expect(
390
+ decideConfinedTool(
391
+ tree,
392
+ "Write",
393
+ { file_path: join(tree, "src", "brand", "new.ts") },
394
+ "write",
395
+ ),
396
+ ).toEqual({ behavior: "allow" });
397
+ expect(
398
+ decideConfinedTool(tree, "Read", { file_path: join(tree, "any.ts") }),
399
+ ).toEqual({ behavior: "allow" });
400
+ });
401
+
402
+ it("denies `<in-tree symlink>/../X`, which a lexical check reads as inside", () => {
403
+ // The escape a resolve()-then-realpath check cannot see, found in the
404
+ // security review of #1015. `path.resolve` removes `..` TEXTUALLY, folding
405
+ // `escape/../STOLEN` to `<tree>/STOLEN` — inside the tree. The kernel
406
+ // removes it only AFTER following `escape`, landing on `<outside>/STOLEN`.
407
+ // A package install routinely creates such links, so this is ordinary.
408
+ // Built as a raw string, NOT with `join`: `path.join` normalises `..` away
409
+ // lexically, which is the very bug under test — it would hand the checker
410
+ // an already-collapsed path and prove nothing.
411
+ const through = `${tree}/escape/../STOLEN.txt`;
412
+ for (const tool of ["Read", "Grep", "Glob"]) {
413
+ const key = tool === "Read" ? "file_path" : "path";
414
+ expect(
415
+ decideConfinedTool(tree, tool, { [key]: through }).behavior,
416
+ `${tool} must not be fooled by a symlink followed by ..`,
417
+ ).toBe("deny");
418
+ }
419
+ expect(
420
+ decideConfinedTool(
421
+ tree,
422
+ "Write",
423
+ { file_path: `${tree}/escape/../planted.sh` },
424
+ "write",
425
+ ).behavior,
426
+ ).toBe("deny");
427
+ });
428
+
429
+ it("denies a RELATIVE path through the same symlink", () => {
430
+ // The half the first fix missed: the absolute branch walked the path
431
+ // correctly while the relative branch still called `resolve()`, folding
432
+ // `..` textually. `Grep`/`Glob` `path` naturally take relative values, so
433
+ // this was the reachable half — and the guard read as closed.
434
+ for (const tool of ["Read", "Grep", "Glob"]) {
435
+ const key = tool === "Read" ? "file_path" : "path";
436
+ expect(
437
+ decideConfinedTool(tree, tool, { [key]: "escape/../STOLEN.txt" })
438
+ .behavior,
439
+ `${tool} must resolve a RELATIVE path the same way`,
440
+ ).toBe("deny");
441
+ }
442
+ expect(
443
+ decideConfinedTool(
444
+ tree,
445
+ "Write",
446
+ { file_path: "escape/../planted.sh" },
447
+ "write",
448
+ ).behavior,
449
+ ).toBe("deny");
450
+ });
451
+
452
+ it("still allows an ordinary `..` that stays inside the tree", () => {
453
+ // The fix must not deny legitimate traversal, or it denies everything.
454
+ expect(
455
+ decideConfinedTool(tree, "Read", {
456
+ file_path: `${tree}/src/../a.ts`,
457
+ }),
458
+ ).toEqual({ behavior: "allow" });
459
+ });
460
+
461
+ it("does not deny everything when the ROOT itself is reached by a symlink", () => {
462
+ // The other direction, and the one that would break the feature silently:
463
+ // on macOS `/tmp` is a symlink to `/private/tmp`, so a worktree path handed
464
+ // in one form and a tool argument in the other must still compare equal.
465
+ expect(isInsideTree(tree, join(tree, "a.ts"))).toBe(true);
466
+ });
467
+ });