@gethmy/harness 1.2.1 → 1.4.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/dist/cli.js +497 -225
- package/dist/index.js +1259 -402
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
- package/src/worktree.ts +114 -2
|
@@ -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
|
+
});
|