@hasna/hooks 0.6.0 → 0.6.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/bin/index.js +11 -1
- package/bin/serve.js +11 -1
- package/dist/index.js +11 -1
- package/hooks/hook-workspace-repos-guard/README.md +63 -0
- package/hooks/hook-workspace-repos-guard/package.json +12 -0
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +312 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +352 -0
- package/hooks/hook-workspace-repos-guard/tsconfig.json +21 -0
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -1955,6 +1955,16 @@ var init_registry = __esm(() => {
|
|
|
1955
1955
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
1956
1956
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
1957
1957
|
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "workspace-repos-guard",
|
|
1960
|
+
displayName: "Workspace Repos Guard",
|
|
1961
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
1962
|
+
version: "0.1.0",
|
|
1963
|
+
category: "Git Safety",
|
|
1964
|
+
event: "PreToolUse",
|
|
1965
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
1966
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
1967
|
+
},
|
|
1958
1968
|
{
|
|
1959
1969
|
name: "checktests",
|
|
1960
1970
|
displayName: "Check Tests",
|
|
@@ -4353,7 +4363,7 @@ function resolveHook(name) {
|
|
|
4353
4363
|
meta
|
|
4354
4364
|
};
|
|
4355
4365
|
}
|
|
4356
|
-
var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/
|
|
4366
|
+
var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
|
|
4357
4367
|
var init_resolve = __esm(() => {
|
|
4358
4368
|
init_registry();
|
|
4359
4369
|
init_manifest();
|
package/bin/serve.js
CHANGED
|
@@ -57,6 +57,16 @@ var HOOKS = [
|
|
|
57
57
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
58
58
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: "workspace-repos-guard",
|
|
62
|
+
displayName: "Workspace Repos Guard",
|
|
63
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
category: "Git Safety",
|
|
66
|
+
event: "PreToolUse",
|
|
67
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
68
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
69
|
+
},
|
|
60
70
|
{
|
|
61
71
|
name: "checktests",
|
|
62
72
|
displayName: "Check Tests",
|
|
@@ -5043,7 +5053,7 @@ function retrustHook(name, scriptPath, version, source) {
|
|
|
5043
5053
|
// src/lib/resolve.ts
|
|
5044
5054
|
import { join as join5 } from "path";
|
|
5045
5055
|
import { existsSync as existsSync5 } from "fs";
|
|
5046
|
-
var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/
|
|
5056
|
+
var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
|
|
5047
5057
|
function resolveHookDir(name) {
|
|
5048
5058
|
const custom2 = readCustomManifest(name);
|
|
5049
5059
|
if (custom2)
|
package/dist/index.js
CHANGED
|
@@ -5336,6 +5336,16 @@ var HOOKS = [
|
|
|
5336
5336
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
5337
5337
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
5338
5338
|
},
|
|
5339
|
+
{
|
|
5340
|
+
name: "workspace-repos-guard",
|
|
5341
|
+
displayName: "Workspace Repos Guard",
|
|
5342
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
5343
|
+
version: "0.1.0",
|
|
5344
|
+
category: "Git Safety",
|
|
5345
|
+
event: "PreToolUse",
|
|
5346
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
5347
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
5348
|
+
},
|
|
5339
5349
|
{
|
|
5340
5350
|
name: "checktests",
|
|
5341
5351
|
displayName: "Check Tests",
|
|
@@ -5811,7 +5821,7 @@ import { fileURLToPath } from "url";
|
|
|
5811
5821
|
import { join as join5 } from "path";
|
|
5812
5822
|
import { existsSync as existsSync5 } from "fs";
|
|
5813
5823
|
init_manifest();
|
|
5814
|
-
var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/
|
|
5824
|
+
var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
|
|
5815
5825
|
function resolveHookDir(name) {
|
|
5816
5826
|
const custom2 = readCustomManifest(name);
|
|
5817
5827
|
if (custom2)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# workspace-repos-guard
|
|
2
|
+
|
|
3
|
+
Codewith-native hook installed as `hooks run workspace-repos-guard`.
|
|
4
|
+
|
|
5
|
+
PreToolUse guard for the canonical workspace structure (knowledge
|
|
6
|
+
`k_mssu9jdq_dgnnu2`): `$HOME/workspace` contains ONLY `repos/` + `scratch/` +
|
|
7
|
+
`AGENTS.md`, and `repos/` contains ONLY GitHub-org folders. Checkouts at
|
|
8
|
+
`$HOME/workspace/repos/<org>/<repo>/` are read/context only.
|
|
9
|
+
|
|
10
|
+
## What it blocks
|
|
11
|
+
|
|
12
|
+
- Any write to `$HOME/workspace/repos` itself (file tools and Bash).
|
|
13
|
+
- Any write that would create a top-level entry directly under `repos/`
|
|
14
|
+
(depth 1 — a stray folder or file at the org level).
|
|
15
|
+
- Any write whose second path segment is not an allowed GitHub org.
|
|
16
|
+
- Any delete (`rm`, `rmdir`, `git clean`, `git rm`, `unlink`, `shred`,
|
|
17
|
+
`trash`, ...) anywhere under `$HOME/workspace/repos`, at any depth,
|
|
18
|
+
including deep inside org checkouts.
|
|
19
|
+
|
|
20
|
+
## What it allows
|
|
21
|
+
|
|
22
|
+
- Reads, always.
|
|
23
|
+
- Writes deeper inside an allowed org folder
|
|
24
|
+
(`repos/<org>/<repo>/...`). Structure only: it deliberately does NOT
|
|
25
|
+
duplicate the `worktree-guard` hook, which owns edits-in-shared-checkouts
|
|
26
|
+
semantics.
|
|
27
|
+
|
|
28
|
+
Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
|
|
29
|
+
forms like `"$HOME"/workspace/repos`, which Bash treats identically to the
|
|
30
|
+
unquoted spelling) are expanded before classification; `apply_patch` tools
|
|
31
|
+
are inspected through their `Add File` / `Update File` / `Delete File`
|
|
32
|
+
markers; Bash relative operands are resolved against the command's cwd when
|
|
33
|
+
it sits under `repos/`; parenthesized command groups
|
|
34
|
+
(`(cd ... && rm -rf ...)`) are unwrapped.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Allowed orgs default to `hasna,hasnaxyz,hasna-internal,hasna-products` and
|
|
39
|
+
are overridable with the `WORKSPACE_REPOS_GUARD_ORGS` env var
|
|
40
|
+
(comma-separated). The home directory is resolved with `os.homedir()` —
|
|
41
|
+
never hardcoded.
|
|
42
|
+
|
|
43
|
+
## Failure mode
|
|
44
|
+
|
|
45
|
+
Fail-open: on any parse or evaluation error the hook responds `continue` so a
|
|
46
|
+
guard defect can never wedge the agent.
|
|
47
|
+
|
|
48
|
+
## Known limitation
|
|
49
|
+
|
|
50
|
+
The guard is a best-effort **structural** guard, not an execution sandbox.
|
|
51
|
+
Variable indirection cannot be caught by pre-expansion inspection: a command
|
|
52
|
+
that builds its target dynamically (`R=...; rm -rf $R`, loops over computed
|
|
53
|
+
paths, scripts downloaded and executed at runtime) is undetectable at hook
|
|
54
|
+
time. The hook inspects literal spellings of the protected path (`~/...`,
|
|
55
|
+
`$HOME/...`, `${HOME}/...`, split-quote forms such as `"$HOME"/...`, the
|
|
56
|
+
resolved absolute home) and relative operands resolved from the command's
|
|
57
|
+
cwd, so anything the shell would expand or indirect through a variable is
|
|
58
|
+
outside its reach. Because it also fails open, it must never be relied on as
|
|
59
|
+
the only protection layer.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
Apache-2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workspace-repos-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codewith-native Workspace Repos Guard hook for @hasna/hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { evaluate, resolveAllowedOrgs } from "./hook";
|
|
5
|
+
|
|
6
|
+
const HOME = homedir();
|
|
7
|
+
const REPOS = join(HOME, "workspace", "repos");
|
|
8
|
+
|
|
9
|
+
function preToolUse(tool_name: string, tool_input: Record<string, unknown>, cwd = join(REPOS, "hasna", "apps")) {
|
|
10
|
+
return { hook_event_name: "PreToolUse", tool_name, tool_input, cwd };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isBlocked(result: { output: { decision?: string; reason?: string } }): string | null {
|
|
14
|
+
if (result.output.decision === "block") return result.output.reason ?? "blocked";
|
|
15
|
+
return null;
|
|
16
|
+
}describe("hook-workspace-repos-guard", () => {
|
|
17
|
+
describe("resolveAllowedOrgs", () => {
|
|
18
|
+
test("defaults to the four canonical GitHub orgs", () => {
|
|
19
|
+
expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-internal", "hasna-products", "hasnaxyz"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parses comma-separated env override, trimming whitespace and empties", () => {
|
|
23
|
+
expect([...resolveAllowedOrgs({ WORKSPACE_REPOS_GUARD_ORGS: " hasna , myorg ,, " })].sort()).toEqual(["hasna", "myorg"]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("positive controls — must continue", () => {
|
|
28
|
+
test("Write deep inside an allowed org checkout continues", () => {
|
|
29
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna", "apps", "apps", "foo", "src", "index.ts") }));
|
|
30
|
+
expect(result.output.continue).toBe(true);
|
|
31
|
+
expect(isBlocked(result)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Edit inside hasna-internal org continues", () => {
|
|
35
|
+
const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "hasna-internal", "platform", "docs", "readme.md") }));
|
|
36
|
+
expect(result.output.continue).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("MultiEdit with relative file_path resolves against cwd and continues inside allowed org", () => {
|
|
40
|
+
const result = evaluate(preToolUse("MultiEdit", { file_path: "apps/hooks/src/lib/registry.ts" }));
|
|
41
|
+
expect(result.output.continue).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("NotebookEdit inside allowed org continues", () => {
|
|
45
|
+
const result = evaluate(preToolUse("NotebookEdit", { notebook_path: join(REPOS, "hasnaxyz", "iapp-probe", "probe.ipynb") }));
|
|
46
|
+
expect(result.output.continue).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("Bash read commands under repos are never blocked", () => {
|
|
50
|
+
const reads = [
|
|
51
|
+
`ls ${REPOS}`,
|
|
52
|
+
`cat ${join(REPOS, "hasna", "apps", "README.md")}`,
|
|
53
|
+
`git -C ${join(REPOS, "hasna", "apps")} status`,
|
|
54
|
+
`cd ${REPOS} && git pull`,
|
|
55
|
+
`find ${join(REPOS, "hasna")} -name AGENTS.md`,
|
|
56
|
+
];
|
|
57
|
+
for (const command of reads) {
|
|
58
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
59
|
+
expect(result.output.continue).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("Bash write deep inside an allowed org checkout continues", () => {
|
|
64
|
+
const commands = [
|
|
65
|
+
`echo note > ${join(REPOS, "hasna", "apps", "scratch.md")}`,
|
|
66
|
+
`touch ${join(REPOS, "hasna", "apps", "apps", "foo", "x.txt")}`,
|
|
67
|
+
`git clone https://github.com/hasna/foo.git ${join(REPOS, "hasna", "foo")}`,
|
|
68
|
+
`mkdir -p ${join(REPOS, "hasna-internal", "platform", "apps")}`,
|
|
69
|
+
];
|
|
70
|
+
for (const command of commands) {
|
|
71
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
72
|
+
expect(result.output.continue).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("tilde/$HOME spellings of deep allowed-org writes continue", () => {
|
|
77
|
+
const commands = [
|
|
78
|
+
`touch ~/workspace/repos/hasna/apps/foo.md`,
|
|
79
|
+
`echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
80
|
+
`mkdir -p "${HOME}/workspace/repos/hasna-internal/platform/apps"`,
|
|
81
|
+
`touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
|
|
82
|
+
`echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
83
|
+
];
|
|
84
|
+
for (const command of commands) {
|
|
85
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
86
|
+
expect(result.output.continue).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("file-tool tilde/$HOME paths deep inside an allowed org continue", () => {
|
|
91
|
+
const results = [
|
|
92
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos/hasna/apps/src/x.ts` })),
|
|
93
|
+
evaluate(preToolUse("Edit", { file_path: `$HOME/workspace/repos/hasnaxyz/iapp-probe/n.ipynb` })),
|
|
94
|
+
evaluate(preToolUse("Write", { file_path: `"${HOME}/workspace/repos/hasna/apps/notes.md"` })),
|
|
95
|
+
evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/hasna/apps/notes2.md` })),
|
|
96
|
+
];
|
|
97
|
+
for (const result of results) {
|
|
98
|
+
expect(result.output.continue).toBe(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("apply_patch Add/Update File deep inside an allowed org continues", () => {
|
|
103
|
+
const result = evaluate(
|
|
104
|
+
preToolUse("apply_patch", {
|
|
105
|
+
patch: `*** Begin Patch\n*** Add File: apps/hooks/src/lib/registry.ts\n+export const x = 1;\n*** End Patch`,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
expect(result.output.continue).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("non-mutating sed without -i, and interpreter without a path, continue", () => {
|
|
112
|
+
const commands = [
|
|
113
|
+
`sed 's/a/b/' ${join(REPOS, "hasna", "apps", "README.md")}`,
|
|
114
|
+
`node -e "console.log('no path here')"`,
|
|
115
|
+
`python3 -c "print('no path here')"`,
|
|
116
|
+
];
|
|
117
|
+
for (const command of commands) {
|
|
118
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
119
|
+
expect(result.output.continue).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("non-PreToolUse events and non-file tools are ignored", () => {
|
|
124
|
+
const result = evaluate({ hook_event_name: "PostToolUse", tool_name: "Write", tool_input: { file_path: join(REPOS, "hasna", "x.ts") } });
|
|
125
|
+
expect(result.output.continue).toBe(true);
|
|
126
|
+
const read = evaluate(preToolUse("Read", { file_path: join(REPOS, "hasna", "apps", "README.md") }));
|
|
127
|
+
expect(read.output.continue).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("negative controls — must block", () => {
|
|
132
|
+
test("Write to ~/workspace/repos itself is blocked", () => {
|
|
133
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "stray.txt") }));
|
|
134
|
+
const reason = isBlocked(result);
|
|
135
|
+
expect(reason).not.toBeNull();
|
|
136
|
+
expect(reason).toContain("workspace-repos-guard");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("Write to the repos root directory entry is blocked", () => {
|
|
140
|
+
const result = evaluate(preToolUse("Edit", { file_path: REPOS }));
|
|
141
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Write whose second segment is not an allowed org is blocked", () => {
|
|
145
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "notanorg", "x", "file.ts") }));
|
|
146
|
+
const reason = isBlocked(result);
|
|
147
|
+
expect(reason).not.toBeNull();
|
|
148
|
+
expect(reason).toContain("notanorg");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("Write creating a top-level entry under repos (org-level, no deeper path) is blocked", () => {
|
|
152
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna") }));
|
|
153
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("rm -rf deep inside an allowed org checkout is blocked", () => {
|
|
157
|
+
const result = evaluate(preToolUse("Bash", { command: `rm -rf ${join(REPOS, "hasna", "apps", ".git")}` }));
|
|
158
|
+
const reason = isBlocked(result);
|
|
159
|
+
expect(reason).not.toBeNull();
|
|
160
|
+
expect(reason).toContain("delete");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("rmdir of an org folder is blocked", () => {
|
|
164
|
+
const result = evaluate(preToolUse("Bash", { command: `rmdir ${join(REPOS, "oldorg")}` }));
|
|
165
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("git clean inside a checkout is blocked", () => {
|
|
169
|
+
const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} clean -fd` }));
|
|
170
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rm -rf of the repos root itself is blocked", () => {
|
|
174
|
+
const result = evaluate(preToolUse("Bash", { command: `rm -rf ${REPOS}` }));
|
|
175
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("cd into repos followed by rm -rf . is blocked via relative resolution", () => {
|
|
179
|
+
const result = evaluate(preToolUse("Bash", { command: `cd ${REPOS} && rm -rf .` }));
|
|
180
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("top-level non-org folder creation via Bash is blocked", () => {
|
|
184
|
+
const commands = [
|
|
185
|
+
`mkdir -p ${join(REPOS, "notanorg")}`,
|
|
186
|
+
`mkdir -p ${join(REPOS, "notanorg", "sub")}`,
|
|
187
|
+
`touch ${join(REPOS, "neworg", "x")}`,
|
|
188
|
+
`mv ${join(REPOS, "hasna", "apps", "a")} ${join(REPOS, "anotherorg")}`,
|
|
189
|
+
];
|
|
190
|
+
for (const command of commands) {
|
|
191
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
192
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("Bash write into a non-allowed org folder is blocked", () => {
|
|
197
|
+
const result = evaluate(preToolUse("Bash", { command: `echo x > ${join(REPOS, "notanorg", "deep", "f.ts")}` }));
|
|
198
|
+
const reason = isBlocked(result);
|
|
199
|
+
expect(reason).not.toBeNull();
|
|
200
|
+
expect(reason).toContain("notanorg");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("delete via git rm is blocked", () => {
|
|
204
|
+
const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} rm src/foo.ts` }));
|
|
205
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("tilde/$HOME spellings of the protected path are blocked", () => {
|
|
209
|
+
const commands = [
|
|
210
|
+
`rm -rf ~/workspace/repos`,
|
|
211
|
+
`rm -rf ~/workspace/repos/hasna/apps`,
|
|
212
|
+
`rm -rf $HOME/workspace/repos/hasna`,
|
|
213
|
+
`rm -rf "${HOME}/workspace/repos"`,
|
|
214
|
+
`rm -rf "$HOME"/workspace/repos`,
|
|
215
|
+
`rm -rf "$HOME"/workspace/repos/hasna/apps`,
|
|
216
|
+
`rm -rf "\${HOME}"/workspace/repos/hasna`,
|
|
217
|
+
`rm -rf "${HOME}"/workspace/repos/hasna`,
|
|
218
|
+
`touch ~/workspace/repos/stray.txt`,
|
|
219
|
+
`mkdir -p ~/workspace/repos/notanorg`,
|
|
220
|
+
`echo x > ~/workspace/repos/notanorg/f.ts`,
|
|
221
|
+
`mv /tmp/x ~/workspace/repos`,
|
|
222
|
+
`truncate -s 0 ~/workspace/repos/stray.log`,
|
|
223
|
+
`rsync -a /tmp/x ~/workspace/repos/`,
|
|
224
|
+
`scp f.txt ~/workspace/repos/`,
|
|
225
|
+
];
|
|
226
|
+
for (const command of commands) {
|
|
227
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
228
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("file-tool tilde/$HOME spellings are blocked", () => {
|
|
233
|
+
const results = [
|
|
234
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos/stray.txt` })),
|
|
235
|
+
evaluate(preToolUse("Write", { file_path: `$HOME/workspace/repos/notanorg/f.ts` })),
|
|
236
|
+
evaluate(preToolUse("Edit", { file_path: `~/workspace/repos/hasna` })),
|
|
237
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos` })),
|
|
238
|
+
evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/stray.txt` })),
|
|
239
|
+
];
|
|
240
|
+
for (const result of results) {
|
|
241
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("subshell-grouped cd + rm is blocked", () => {
|
|
246
|
+
const commands = [
|
|
247
|
+
`(cd ~/workspace/repos && rm -rf hasna)`,
|
|
248
|
+
`(cd ${REPOS} && rm -rf hasna)`,
|
|
249
|
+
`( cd ${REPOS} && touch stray.txt )`,
|
|
250
|
+
];
|
|
251
|
+
for (const command of commands) {
|
|
252
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
253
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("cwd-relative deletes without an explicit cd are blocked", () => {
|
|
258
|
+
const fromCheckout = evaluate(preToolUse("Bash", { command: `rm -rf ../` }, join(REPOS, "hasna", "apps")));
|
|
259
|
+
expect(isBlocked(fromCheckout)).not.toBeNull();
|
|
260
|
+
const fromRoot = evaluate(preToolUse("Bash", { command: `mkdir -p notanorg` }, REPOS));
|
|
261
|
+
expect(isBlocked(fromRoot)).not.toBeNull();
|
|
262
|
+
const bareRm = evaluate(preToolUse("Bash", { command: `rm -rf .` }, join(REPOS, "hasnaxyz")));
|
|
263
|
+
expect(isBlocked(bareRm)).not.toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("mutating verbs writing under the protected root are blocked", () => {
|
|
267
|
+
const commands = [
|
|
268
|
+
`sed -i 's/x/y/' ~/workspace/repos/stray.txt`,
|
|
269
|
+
`rsync -a ${join(REPOS, "hasna")} /tmp/out`,
|
|
270
|
+
`scp -r ${join(REPOS, "hasna")} other:/tmp/`,
|
|
271
|
+
`truncate -s 0 ${join(REPOS, "notanorg", "f.ts")}`,
|
|
272
|
+
`python3 -c "open('~/workspace/repos/stray.txt','w')"`,
|
|
273
|
+
`node -e "require('fs').writeFileSync('${join(REPOS, "notanorg", "f.ts")}','x')"`,
|
|
274
|
+
];
|
|
275
|
+
for (const command of commands) {
|
|
276
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
277
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("apply_patch Delete File and shallow Add File are blocked", () => {
|
|
282
|
+
const del = evaluate(
|
|
283
|
+
preToolUse("ApplyPatch", {
|
|
284
|
+
patch: `*** Begin Patch\n*** Delete File: src/foo.ts\n*** End Patch`,
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
expect(isBlocked(del)).not.toBeNull();
|
|
288
|
+
const shallow = evaluate(
|
|
289
|
+
preToolUse("functions.apply_patch", {
|
|
290
|
+
patch: `*** Begin Patch\n*** Add File: ../../stray.txt\n+x\n*** End Patch`,
|
|
291
|
+
})
|
|
292
|
+
);
|
|
293
|
+
expect(isBlocked(shallow)).not.toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("orgs env override", () => {
|
|
298
|
+
test("override widens and narrows the allowed set", () => {
|
|
299
|
+
const previous = process.env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
300
|
+
try {
|
|
301
|
+
process.env.WORKSPACE_REPOS_GUARD_ORGS = "hasna,myorg";
|
|
302
|
+
const allowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "myorg", "proj", "f.ts") }));
|
|
303
|
+
expect(allowed.output.continue).toBe(true);
|
|
304
|
+
const narrowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasnaxyz", "iapp-x", "f.ts") }));
|
|
305
|
+
expect(isBlocked(narrowed)).not.toBeNull();
|
|
306
|
+
} finally {
|
|
307
|
+
if (previous === undefined) delete process.env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
308
|
+
else process.env.WORKSPACE_REPOS_GUARD_ORGS = previous;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse hook: workspace-repos-guard
|
|
5
|
+
*
|
|
6
|
+
* Guards the canonical workspace structure (knowledge k_mssu9jdq_dgnnu2):
|
|
7
|
+
* $HOME/workspace contains ONLY repos/ + scratch/ + AGENTS.md, and repos/
|
|
8
|
+
* contains ONLY GitHub-org folders. Checkouts at
|
|
9
|
+
* $HOME/workspace/repos/<org>/<repo>/ are read/context only.
|
|
10
|
+
*
|
|
11
|
+
* Structure-only guard — it deliberately does NOT duplicate the worktree-guard
|
|
12
|
+
* hook, which owns edits-in-shared-checkouts semantics.
|
|
13
|
+
*
|
|
14
|
+
* BLOCKS:
|
|
15
|
+
* - any write to $HOME/workspace/repos itself (file tools and Bash),
|
|
16
|
+
* - any write that would create a top-level entry under repos/ (depth <= 1),
|
|
17
|
+
* - any write whose second path segment is not an allowed GitHub org,
|
|
18
|
+
* - any delete (rm, rmdir, git clean, git rm, unlink, shred, trash, ...)
|
|
19
|
+
* anywhere under $HOME/workspace/repos, at any depth.
|
|
20
|
+
*
|
|
21
|
+
* ALLOWS (structure only):
|
|
22
|
+
* - reads, always,
|
|
23
|
+
* - writes deeper inside an allowed org folder (repos/<org>/<repo>/...).
|
|
24
|
+
*
|
|
25
|
+
* Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
|
|
26
|
+
* before classification in Bash targets, file-tool paths, cd operands and
|
|
27
|
+
* apply_patch file markers. Bash relative operands (`.`, `..`, bare names)
|
|
28
|
+
* are resolved against the command's cwd when it sits under the repos root,
|
|
29
|
+
* and against an explicit `cd` into the root when one is present. apply_patch
|
|
30
|
+
* tools are inspected through their `*** Add File:` / `*** Update File:` /
|
|
31
|
+
* `*** Delete File:` markers. Parenthesized command groups are unwrapped.
|
|
32
|
+
*
|
|
33
|
+
* Allowed orgs default to hasna,hasnaxyz,hasna-internal,hasna-products and are
|
|
34
|
+
* overridable with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated).
|
|
35
|
+
* Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
|
|
36
|
+
* or evaluation error so a guard defect cannot wedge the agent.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { homedir } from "os";
|
|
40
|
+
import { isAbsolute, join, normalize, relative, resolve, sep } from "path";
|
|
41
|
+
import {
|
|
42
|
+
getCommand,
|
|
43
|
+
readInput,
|
|
44
|
+
respond,
|
|
45
|
+
warn,
|
|
46
|
+
type CodewithHookInput,
|
|
47
|
+
type CodewithHookOutput,
|
|
48
|
+
} from "../../codewith-native-common";
|
|
49
|
+
|
|
50
|
+
const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
51
|
+
|
|
52
|
+
const PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
53
|
+
|
|
54
|
+
const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-internal", "hasna-products"];
|
|
55
|
+
|
|
56
|
+
const RULE = "workspace-repos-guard";
|
|
57
|
+
|
|
58
|
+
function reposRoot(home: string): string {
|
|
59
|
+
return join(home, "workspace", "repos");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
|
|
63
|
+
const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
64
|
+
if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
|
|
65
|
+
return new Set(
|
|
66
|
+
raw
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Operation = "read" | "write" | "delete";
|
|
74
|
+
|
|
75
|
+
interface Verdict {
|
|
76
|
+
blocked: boolean;
|
|
77
|
+
reason?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Classify a single absolute target path against the repos root. Structure
|
|
82
|
+
* only: a write at depth >= 2 inside an allowed org passes, and anything else
|
|
83
|
+
* (deletes at any depth, writes at the root or org level, writes into a
|
|
84
|
+
* non-allowed org) is blocked. Paths outside the repos root are not this
|
|
85
|
+
* hook's concern and pass.
|
|
86
|
+
*/
|
|
87
|
+
export function classifyPath(target: string, root: string, orgs: Set<string>, op: Operation): Verdict {
|
|
88
|
+
if (op === "read") return { blocked: false };
|
|
89
|
+
let rel = relative(root, target);
|
|
90
|
+
if (rel === "") {
|
|
91
|
+
if (op === "delete") return { blocked: true, reason: `[${RULE}] delete of the repos root is forbidden: ${root}` };
|
|
92
|
+
return { blocked: true, reason: `[${RULE}] writes directly to the repos root are forbidden: ${root}` };
|
|
93
|
+
}
|
|
94
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
95
|
+
return { blocked: false };
|
|
96
|
+
}
|
|
97
|
+
const segments = rel.split(sep).filter(Boolean);
|
|
98
|
+
|
|
99
|
+
if (op === "delete") {
|
|
100
|
+
return { blocked: true, reason: `[${RULE}] delete under ~/workspace/repos is forbidden at any depth: ${target}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (segments.length <= 1) {
|
|
104
|
+
return {
|
|
105
|
+
blocked: true,
|
|
106
|
+
reason: `[${RULE}] writing would create a top-level entry directly under repos/; only GitHub-org folders belong there: ${target}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const org = segments[0];
|
|
111
|
+
if (!orgs.has(org)) {
|
|
112
|
+
return {
|
|
113
|
+
blocked: true,
|
|
114
|
+
reason: `[${RULE}] '${org}' is not an allowed GitHub org under ~/workspace/repos (allowed: ${[...orgs].join(", ")}): ${target}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { blocked: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
|
|
125
|
+
* unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
|
|
126
|
+
* everything else (status/pull/fetch/log/diff/...) is read. Sed without an
|
|
127
|
+
* in-place flag is a stream filter (redirection is caught separately);
|
|
128
|
+
* rsync/scp/truncate and inline interpreters (python3 -c, node -e, bun -e)
|
|
129
|
+
* are treated as writes — conservative: they only matter when a path under
|
|
130
|
+
* the protected root is also present.
|
|
131
|
+
*/
|
|
132
|
+
function segmentOperation(segment: string): Operation {
|
|
133
|
+
const trimmed = segment.trim();
|
|
134
|
+
if (!trimmed) return "read";
|
|
135
|
+
|
|
136
|
+
if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
|
|
137
|
+
if (/\bgit\b/.test(trimmed)) {
|
|
138
|
+
if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed)) return "delete";
|
|
139
|
+
if (/\bgit\b[^;&|]*\b(?:clone|init)\b/.test(trimmed)) return "write";
|
|
140
|
+
return "read";
|
|
141
|
+
}
|
|
142
|
+
if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed)) return "write";
|
|
143
|
+
if (/\b(?:curl|wget)\b/.test(trimmed)) {
|
|
144
|
+
if (WRITE_FLAGS.test(trimmed)) return "write";
|
|
145
|
+
return "read";
|
|
146
|
+
}
|
|
147
|
+
if (/\bsed\b/.test(trimmed)) {
|
|
148
|
+
if (/(?:^|\s)-i\S*(?:\s|$)|--in-place(?:\s|$)/.test(trimmed)) return "write";
|
|
149
|
+
// sed without -i is a filter; `>` redirection is caught by the check below.
|
|
150
|
+
} else if (/\b(?:rsync|scp|truncate)\b/.test(trimmed)) {
|
|
151
|
+
return "write";
|
|
152
|
+
} else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
|
|
153
|
+
return "write";
|
|
154
|
+
}
|
|
155
|
+
if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed)) return "write";
|
|
156
|
+
return "read";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface PathTarget {
|
|
160
|
+
path: string;
|
|
161
|
+
op: Operation;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Expand one token's home spelling (`~`, `~/...`, `$HOME`, `$HOME/...`,
|
|
166
|
+
* `${HOME}`, `${HOME}/...`) to the resolved home, tolerating a single layer
|
|
167
|
+
* of wrapping single/double quotes and a closing quote between the home
|
|
168
|
+
* token and its path suffix (`"$HOME"/x`, `"${HOME}"/x` — Bash treats these
|
|
169
|
+
* identically to the unquoted form, because double quotes allow parameter
|
|
170
|
+
* expansion and adjacent quoted/unquoted parts concatenate into one word).
|
|
171
|
+
* Non-home spellings are returned unchanged.
|
|
172
|
+
*/
|
|
173
|
+
export function expandHomeSpelling(token: string, home: string): string {
|
|
174
|
+
let body = token.trim();
|
|
175
|
+
const quoted = body.match(/^(['"])([\s\S]*)\1$/);
|
|
176
|
+
if (quoted) body = quoted[2];
|
|
177
|
+
// Split-quote forms — `"$HOME"/x`, `"${HOME}"/x`, `"/home/hasna"/x`: the
|
|
178
|
+
// quotes wrap only the home token, and Bash concatenates the adjacent
|
|
179
|
+
// quoted and unquoted parts into the same word as the unquoted spelling.
|
|
180
|
+
body = body.replace(/^"*\$HOME"*\//, "$HOME/");
|
|
181
|
+
body = body.replace(/^"*\$\{HOME\}"*\//, "${HOME}/");
|
|
182
|
+
if (body === "~") return home;
|
|
183
|
+
if (body.startsWith("~/")) return join(home, body.slice(2));
|
|
184
|
+
if (body === "$HOME" || body === "${HOME}") return home;
|
|
185
|
+
if (body.startsWith("$HOME/")) return join(home, body.slice("$HOME/".length));
|
|
186
|
+
if (body.startsWith("${HOME}/")) return join(home, body.slice("${HOME}/".length));
|
|
187
|
+
const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
|
|
188
|
+
if (homeMatch) return home + "/" + body.slice(homeMatch[0].length);
|
|
189
|
+
return token;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Strip one level of wrapping shell-group punctuation from a segment so a
|
|
194
|
+
* subshell group like `(cd ~/workspace/repos && rm -rf hasna)` is analysed as
|
|
195
|
+
* its parts after the `&&` split.
|
|
196
|
+
*/
|
|
197
|
+
function unwrapSegment(segment: string): string {
|
|
198
|
+
let s = segment.trim();
|
|
199
|
+
while (s.startsWith("(") || s.startsWith("{")) s = s.slice(1).trim();
|
|
200
|
+
while (s.endsWith(")") || s.endsWith("}")) s = s.slice(0, -1).trim();
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function regexEscape(text: string): string {
|
|
205
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract workspace-repos targets from a Bash command. Recognises explicit
|
|
210
|
+
* `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references (expanded
|
|
211
|
+
* before classification), plus a trailing relative operand (`.`, `..`, a bare
|
|
212
|
+
* name) when the command `cd`s into the repos root or the command's cwd
|
|
213
|
+
* already sits under it. Tokens outside the repos root are ignored; reads are
|
|
214
|
+
* returned so callers can decide (they are never blocked).
|
|
215
|
+
*/
|
|
216
|
+
export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
|
|
217
|
+
if (!command) return [];
|
|
218
|
+
const root = reposRoot(home);
|
|
219
|
+
const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
|
|
220
|
+
|
|
221
|
+
const targets: PathTarget[] = [];
|
|
222
|
+
let cwdUnderRepos: string | null = null;
|
|
223
|
+
|
|
224
|
+
for (const rawSegment of segments) {
|
|
225
|
+
const segment = unwrapSegment(rawSegment);
|
|
226
|
+
const op = segmentOperation(segment);
|
|
227
|
+
|
|
228
|
+
const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
|
|
229
|
+
if (cdMatch) {
|
|
230
|
+
const cdTarget = expandHomeSpelling(cdMatch[1], home);
|
|
231
|
+
if (cdTarget && (cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) cwdUnderRepos = cdTarget;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const homeLiteral = regexEscape(home);
|
|
235
|
+
const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/workspace/repos`);
|
|
236
|
+
const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
|
|
237
|
+
let m: RegExpExecArray | null;
|
|
238
|
+
let foundExplicit = false;
|
|
239
|
+
while ((m = re.exec(segment)) !== null) {
|
|
240
|
+
foundExplicit = true;
|
|
241
|
+
const expanded = expandHomeSpelling(m[0], home);
|
|
242
|
+
targets.push({ path: normalize(expanded).replace(/\/+$/, ""), op });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!foundExplicit && (op === "delete" || op === "write")) {
|
|
246
|
+
const base =
|
|
247
|
+
cwdUnderRepos ?? (cwd === root || cwd.startsWith(`${root}${sep}`) ? cwd : null);
|
|
248
|
+
if (base) {
|
|
249
|
+
const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
|
|
250
|
+
if (relMatch) {
|
|
251
|
+
targets.push({ path: normalize(resolve(base, relMatch[1])), op });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return targets;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const PATCH_FILE_RE = /^\s*\*\*\*\s+(Add File|Update File|Delete File):\s+(\S+)\s*$/gm;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract targets from an apply_patch payload: the `*** Add File:` /
|
|
264
|
+
* `*** Update File:` / `*** Delete File:` markers, resolved against cwd.
|
|
265
|
+
* Add/Update classify as write; Delete classifies as delete.
|
|
266
|
+
*/
|
|
267
|
+
export function patchTargets(patch: string, cwd: string): PathTarget[] {
|
|
268
|
+
const targets: PathTarget[] = [];
|
|
269
|
+
let m: RegExpExecArray | null;
|
|
270
|
+
while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
|
|
271
|
+
const op: Operation = m[1] === "Delete File" ? "delete" : "write";
|
|
272
|
+
targets.push({ path: normalize(resolve(cwd, m[2])), op });
|
|
273
|
+
}
|
|
274
|
+
return targets;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput; warnings: string[] } {
|
|
278
|
+
const warnings: string[] = [];
|
|
279
|
+
if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
|
|
280
|
+
|
|
281
|
+
const tool = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
282
|
+
const home = homedir();
|
|
283
|
+
const root = reposRoot(home);
|
|
284
|
+
const orgs = resolveAllowedOrgs();
|
|
285
|
+
|
|
286
|
+
if (tool === "Bash") {
|
|
287
|
+
const command = getCommand(input);
|
|
288
|
+
if (!command) return { output: { continue: true }, warnings };
|
|
289
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
290
|
+
const targets = bashTargets(command, home, cwd);
|
|
291
|
+
for (const target of targets) {
|
|
292
|
+
const verdict = classifyPath(target.path, root, orgs, target.op);
|
|
293
|
+
if (verdict.blocked) {
|
|
294
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { output: { continue: true }, warnings };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const toolInput = input.tool_input ?? {};
|
|
301
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
302
|
+
|
|
303
|
+
if (PATCH_TOOLS.has(tool)) {
|
|
304
|
+
const patch = typeof toolInput.patch === "string" ? toolInput.patch : "";
|
|
305
|
+
if (!patch) return { output: { continue: true }, warnings };
|
|
306
|
+
const targets = patchTargets(patch, cwd);
|
|
307
|
+
for (const target of targets) {
|
|
308
|
+
const verdict = classifyPath(target.path, root, orgs, target.op);
|
|
309
|
+
if (verdict.blocked) {
|
|
310
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { output: { continue: true }, warnings };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!FILE_WRITE_TOOLS.has(tool)) return { output: { continue: true }, warnings };
|
|
317
|
+
|
|
318
|
+
const raw =
|
|
319
|
+
typeof toolInput.file_path === "string"
|
|
320
|
+
? toolInput.file_path
|
|
321
|
+
: typeof toolInput.path === "string"
|
|
322
|
+
? toolInput.path
|
|
323
|
+
: typeof toolInput.notebook_path === "string"
|
|
324
|
+
? toolInput.notebook_path
|
|
325
|
+
: null;
|
|
326
|
+
if (!raw) return { output: { continue: true }, warnings };
|
|
327
|
+
|
|
328
|
+
const expanded = expandHomeSpelling(raw, home);
|
|
329
|
+
const target = isAbsolute(expanded) ? normalize(expanded) : resolve(cwd, expanded);
|
|
330
|
+
const verdict = classifyPath(target, root, orgs, "write");
|
|
331
|
+
if (verdict.blocked) {
|
|
332
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
333
|
+
}
|
|
334
|
+
return { output: { continue: true }, warnings };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function run(): Promise<void> {
|
|
338
|
+
const input = readInput();
|
|
339
|
+
try {
|
|
340
|
+
const { output, warnings } = evaluate(input);
|
|
341
|
+
for (const message of warnings) warn(message);
|
|
342
|
+
respond(output);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
warn(`${RULE} failed open: ${error instanceof Error ? error.message : String(error)}`);
|
|
345
|
+
respond({ continue: true });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (import.meta.main) {
|
|
350
|
+
await run();
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"types": ["bun-types"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|
package/package.json
CHANGED