@hasna/hooks 0.10.2 → 0.10.4
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 +41 -0
- package/bin/hooks-mcp.js +6 -3
- package/bin/index.js +1232 -319
- package/bin/native-safety-entry.js +325 -80
- package/bin/serve.js +9 -9
- package/dist/index.js +16 -13
- package/dist/lib/native-safety.d.ts +1 -0
- package/dist/native-safety.d.ts +58 -0
- package/dist/native-safety.js +363 -0
- package/dist/sdk/index.js +1 -1
- package/hooks/hook-trash-guard/src/binary.ts +158 -0
- package/hooks/hook-trash-guard/src/hook.ts +10 -40
- package/hooks/hook-workspace-repos-guard/README.md +30 -8
- package/hooks/hook-workspace-repos-guard/src/hook.ts +181 -42
- package/hooks/native-safety-entry.ts +2 -2
- package/package.json +6 -2
- package/scripts/validate-package.ts +12 -3
|
@@ -1,13 +1,198 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// hooks/native-safety-entry.ts
|
|
3
3
|
import { homedir as homedir2 } from "os";
|
|
4
|
-
import { isAbsolute as
|
|
4
|
+
import { isAbsolute as isAbsolute4 } from "path";
|
|
5
5
|
|
|
6
6
|
// hooks/hook-trash-guard/src/hook.ts
|
|
7
|
-
import {
|
|
8
|
-
import { readFileSync, realpathSync, statSync } from "fs";
|
|
9
|
-
import { spawnSync } from "child_process";
|
|
7
|
+
import { isAbsolute as isAbsolute2, join as join2, normalize, resolve as resolve2, sep } from "path";
|
|
10
8
|
|
|
9
|
+
// hooks/hook-trash-guard/src/binary.ts
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from "path";
|
|
11
|
+
import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "fs";
|
|
12
|
+
import { spawnSync } from "child_process";
|
|
13
|
+
var BINARY_FAILURES = {
|
|
14
|
+
missing: "No verified @hasna/trash guard was found on PATH.",
|
|
15
|
+
unsafe_executable: "The Trash candidate is not an owned executable file or is writable by group or others.",
|
|
16
|
+
unsafe_manifest: "The Trash package manifest is not a safe owned regular file.",
|
|
17
|
+
unsafe_directory: "A Trash PATH or package directory is not owned safely or is writable by group or others.",
|
|
18
|
+
unsafe_interpreter: "The Trash interpreter is missing, unsupported, or not a safe owned executable on its actual PATH.",
|
|
19
|
+
unrecognized_package: "The candidate is not the registered @hasna/trash executable.",
|
|
20
|
+
identity_failed: "The installed Trash identity probe failed or exceeded its deadline.",
|
|
21
|
+
unsupported_protocol: "The installed Trash version does not expose the required guard protocol."
|
|
22
|
+
};
|
|
23
|
+
function trustedOwner(uid) {
|
|
24
|
+
return uid === 0 || uid === process.getuid?.();
|
|
25
|
+
}
|
|
26
|
+
function safeExecutableFile(path, systemBinAlias = false) {
|
|
27
|
+
let current = path;
|
|
28
|
+
for (let hop = 0;hop < 32; hop++) {
|
|
29
|
+
if (!isAbsolute(current) || current.length > 4096 || !safeBinaryDirectory("/"))
|
|
30
|
+
return false;
|
|
31
|
+
const parts = current.split("/"), name = parts.pop();
|
|
32
|
+
if (!name || name === "." || name === "..")
|
|
33
|
+
return false;
|
|
34
|
+
let parent = "/";
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
if (!part || part === ".")
|
|
37
|
+
continue;
|
|
38
|
+
if (part === "..") {
|
|
39
|
+
parent = dirname(parent);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const next = join(parent, part), info2 = lstatSync(next);
|
|
43
|
+
if (systemBinAlias && next === "/bin" && info2.isSymbolicLink() && info2.uid === 0) {
|
|
44
|
+
const target = realpathSync(next);
|
|
45
|
+
if (!safeBinaryDirectory(target))
|
|
46
|
+
return false;
|
|
47
|
+
parent = target;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!info2.isDirectory() || !trustedOwner(info2.uid) || info2.mode & 18)
|
|
51
|
+
return false;
|
|
52
|
+
parent = next;
|
|
53
|
+
}
|
|
54
|
+
const candidate = join(parent, name), info = lstatSync(candidate);
|
|
55
|
+
if (!trustedOwner(info.uid))
|
|
56
|
+
return false;
|
|
57
|
+
if (info.isSymbolicLink()) {
|
|
58
|
+
const target = readlinkSync(candidate);
|
|
59
|
+
current = isAbsolute(target) ? target : parent + "/" + target;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return info.isFile() && Boolean(info.mode & 73) && !(info.mode & 18);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function safeInterpreterSearchDirectory(path) {
|
|
67
|
+
if (!isAbsolute(path) || resolve(path) !== (path.replace(/\/+$/, "") || "/"))
|
|
68
|
+
return false;
|
|
69
|
+
for (let cursor = resolve(path);; cursor = dirname(cursor)) {
|
|
70
|
+
try {
|
|
71
|
+
lstatSync(cursor);
|
|
72
|
+
return safeBinaryDirectory(cursor);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== "ENOENT")
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (dirname(cursor) === cursor)
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function safeScriptInterpreter(executable, pathValue) {
|
|
82
|
+
const fd = openSync(executable, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
83
|
+
let header;
|
|
84
|
+
try {
|
|
85
|
+
if (!fstatSync(fd).isFile())
|
|
86
|
+
return false;
|
|
87
|
+
const bytes = Buffer.alloc(256), length = readSync(fd, bytes, 0, bytes.length, 0);
|
|
88
|
+
header = bytes.subarray(0, length).toString("utf8").split(`
|
|
89
|
+
`)[0];
|
|
90
|
+
} finally {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
const direct = /^#!(\/[A-Za-z0-9_./-]+)$/.exec(header);
|
|
94
|
+
if (direct && direct[1] !== "/usr/bin/env")
|
|
95
|
+
return safeExecutableFile(direct[1], true);
|
|
96
|
+
if (header !== "#!/usr/bin/env bun" || !safeExecutableFile("/usr/bin/env", true))
|
|
97
|
+
return false;
|
|
98
|
+
for (const dir of pathValue.split(":").slice(0, 64)) {
|
|
99
|
+
if (!safeInterpreterSearchDirectory(dir))
|
|
100
|
+
return false;
|
|
101
|
+
const interpreter = resolve(dir, "bun");
|
|
102
|
+
try {
|
|
103
|
+
if (!statSync(interpreter).isFile())
|
|
104
|
+
continue;
|
|
105
|
+
accessSync(interpreter, constants.X_OK);
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
return safeExecutableFile(interpreter, true);
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
function safeBinaryDirectory(path) {
|
|
114
|
+
let current = path;
|
|
115
|
+
for (;; ) {
|
|
116
|
+
const info = lstatSync(current);
|
|
117
|
+
if (!info.isDirectory() || !trustedOwner(info.uid) || (info.mode & 18) !== 0)
|
|
118
|
+
return false;
|
|
119
|
+
const parent = dirname(current);
|
|
120
|
+
if (parent === current)
|
|
121
|
+
return true;
|
|
122
|
+
current = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function inspectTrashBinary(env = process.env) {
|
|
126
|
+
const pathValue = env.PATH ?? "";
|
|
127
|
+
const deadline = Date.now() + 2000;
|
|
128
|
+
let failure = "missing";
|
|
129
|
+
const reject = (code) => {
|
|
130
|
+
if (failure === "missing" || failure === "unrecognized_package" || code.startsWith("unsafe_"))
|
|
131
|
+
failure = code;
|
|
132
|
+
};
|
|
133
|
+
for (const dir of pathValue.split(":").slice(0, 64)) {
|
|
134
|
+
if (!isAbsolute(dir) || Date.now() >= deadline)
|
|
135
|
+
continue;
|
|
136
|
+
const candidate = resolve(dir, "trash");
|
|
137
|
+
try {
|
|
138
|
+
const stat = statSync(candidate);
|
|
139
|
+
if (!stat.isFile() || !trustedOwner(stat.uid) || (stat.mode & 73) === 0 || (stat.mode & 18) !== 0) {
|
|
140
|
+
reject("unsafe_executable");
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const executable = realpathSync(candidate);
|
|
144
|
+
if (!safeBinaryDirectory(resolve(dir)) || !safeBinaryDirectory(dirname(executable))) {
|
|
145
|
+
reject("unsafe_directory");
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!safeExecutableFile(candidate)) {
|
|
149
|
+
reject("unsafe_directory");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const packageRoot = resolve(dirname(executable), "../..");
|
|
153
|
+
const manifestPath = join(packageRoot, "package.json");
|
|
154
|
+
const manifestStat = statSync(manifestPath);
|
|
155
|
+
if (!manifestStat.isFile() || !trustedOwner(manifestStat.uid) || manifestStat.size > 16384 || (manifestStat.mode & 18) !== 0 || lstatSync(manifestPath).isSymbolicLink()) {
|
|
156
|
+
reject("unsafe_manifest");
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
160
|
+
if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" || typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) {
|
|
161
|
+
reject("unrecognized_package");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (!safeScriptInterpreter(executable, pathValue)) {
|
|
166
|
+
reject("unsafe_interpreter");
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
reject("unsafe_interpreter");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const probe = spawnSync(candidate, ["--identity"], {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
timeout: Math.max(1, Math.min(500, deadline - Date.now())),
|
|
176
|
+
maxBuffer: 2048,
|
|
177
|
+
env: { PATH: pathValue },
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
179
|
+
});
|
|
180
|
+
if (probe.error || probe.status !== 0) {
|
|
181
|
+
reject("identity_failed");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const identity = JSON.parse(probe.stdout);
|
|
185
|
+
if (identity.name === "@hasna/trash" && identity.version === manifest.version && identity.guardProtocol === "hasna.trash.guard.v1")
|
|
186
|
+
return { ok: true, path: candidate, version: manifest.version };
|
|
187
|
+
reject("unsupported_protocol");
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, code: failure };
|
|
191
|
+
}
|
|
192
|
+
function findTrashBinary(env = process.env) {
|
|
193
|
+
const result = inspectTrashBinary(env);
|
|
194
|
+
return result.ok ? result.path : null;
|
|
195
|
+
}
|
|
11
196
|
// hooks/codewith-native-common.ts
|
|
12
197
|
function getCommand(input) {
|
|
13
198
|
const command = input.tool_input?.command;
|
|
@@ -495,17 +680,17 @@ function rmOperands(segment, verbIndex) {
|
|
|
495
680
|
return operands;
|
|
496
681
|
}
|
|
497
682
|
function handoffRoots(home) {
|
|
498
|
-
return HANDOFF_ROOTS.map((suffix) =>
|
|
683
|
+
return HANDOFF_ROOTS.map((suffix) => join2(home, ...suffix.split("/")));
|
|
499
684
|
}
|
|
500
685
|
function expandHomeSpelling(token, home) {
|
|
501
686
|
if (token === "~" || token === "$HOME" || token === "${HOME}")
|
|
502
687
|
return home;
|
|
503
688
|
if (token.startsWith("~/"))
|
|
504
|
-
return
|
|
689
|
+
return join2(home, token.slice(2));
|
|
505
690
|
if (token.startsWith("$HOME/"))
|
|
506
|
-
return
|
|
691
|
+
return join2(home, token.slice("$HOME/".length));
|
|
507
692
|
if (token.startsWith("${HOME}/"))
|
|
508
|
-
return
|
|
693
|
+
return join2(home, token.slice("${HOME}/".length));
|
|
509
694
|
return token;
|
|
510
695
|
}
|
|
511
696
|
function resolveLiteralTarget(raw, cwd, home) {
|
|
@@ -527,7 +712,7 @@ function resolveLiteralTarget(raw, cwd, home) {
|
|
|
527
712
|
return null;
|
|
528
713
|
decoded = expanded;
|
|
529
714
|
}
|
|
530
|
-
const absolute =
|
|
715
|
+
const absolute = isAbsolute2(decoded) ? normalize(decoded) : resolve2(cwd, decoded);
|
|
531
716
|
const trimmed = absolute.replace(/\/+$/, "");
|
|
532
717
|
return trimmed === "" ? "/" : trimmed;
|
|
533
718
|
}
|
|
@@ -535,7 +720,7 @@ function isUnder(target, root) {
|
|
|
535
720
|
return target === root || target.startsWith(`${root}${sep}`);
|
|
536
721
|
}
|
|
537
722
|
function isScratchpadContent(target, home) {
|
|
538
|
-
const prefix = `${
|
|
723
|
+
const prefix = `${join2(home, ".hasna", "scratchpad", "scratch")}${sep}`;
|
|
539
724
|
if (!target.startsWith(prefix))
|
|
540
725
|
return false;
|
|
541
726
|
const [session, firstChild] = target.slice(prefix.length).split(sep);
|
|
@@ -554,7 +739,7 @@ function protectedTargetReason(target, home) {
|
|
|
554
739
|
return root === "/" ? `\`${target}\` is the filesystem root, which is never deleted.` : `\`${target}\` is the system root \`${root}\` (or an ancestor of it), which is never deleted or trashed.`;
|
|
555
740
|
}
|
|
556
741
|
for (const suffix of PROTECTED_HOME_TREES) {
|
|
557
|
-
const root =
|
|
742
|
+
const root = join2(home, suffix);
|
|
558
743
|
if (suffix === ".hasna" && isScratchpadContent(target, home))
|
|
559
744
|
continue;
|
|
560
745
|
if (!isUnder(target, root) && !isUnder(root, target))
|
|
@@ -721,42 +906,6 @@ function gitHasDryRun(segment, verbIndex) {
|
|
|
721
906
|
const options = segment.words.slice(verbIndex + 1).map((word) => decodeWord(word.text));
|
|
722
907
|
return options.includes("-n") || options.includes("--dry-run");
|
|
723
908
|
}
|
|
724
|
-
function findTrashBinary(env = process.env) {
|
|
725
|
-
const pathValue = env.PATH ?? "";
|
|
726
|
-
const deadline = Date.now() + 2000;
|
|
727
|
-
for (const dir of pathValue.split(":").slice(0, 64)) {
|
|
728
|
-
if (!isAbsolute(dir) || Date.now() >= deadline)
|
|
729
|
-
continue;
|
|
730
|
-
const candidate = resolve(dir, "trash");
|
|
731
|
-
try {
|
|
732
|
-
const stat = statSync(candidate);
|
|
733
|
-
if (!stat.isFile() || (stat.mode & 73) === 0 || (stat.mode & 18) !== 0)
|
|
734
|
-
continue;
|
|
735
|
-
const executable = realpathSync(candidate);
|
|
736
|
-
const packageRoot = resolve(dirname(executable), "../..");
|
|
737
|
-
const manifestPath = join(packageRoot, "package.json");
|
|
738
|
-
const manifestStat = statSync(manifestPath);
|
|
739
|
-
if (!manifestStat.isFile() || manifestStat.size > 16384 || (manifestStat.mode & 18) !== 0)
|
|
740
|
-
continue;
|
|
741
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
742
|
-
if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" || typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable)
|
|
743
|
-
continue;
|
|
744
|
-
const probe = spawnSync(candidate, ["--identity"], {
|
|
745
|
-
encoding: "utf8",
|
|
746
|
-
timeout: Math.max(1, Math.min(500, deadline - Date.now())),
|
|
747
|
-
maxBuffer: 2048,
|
|
748
|
-
env: { PATH: pathValue },
|
|
749
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
750
|
-
});
|
|
751
|
-
if (probe.error || probe.status !== 0)
|
|
752
|
-
continue;
|
|
753
|
-
const identity = JSON.parse(probe.stdout);
|
|
754
|
-
if (identity.name === "@hasna/trash" && identity.version === manifest.version && identity.guardProtocol === "hasna.trash.guard.v1")
|
|
755
|
-
return candidate;
|
|
756
|
-
} catch {}
|
|
757
|
-
}
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
909
|
function shellQuoteWord(word) {
|
|
761
910
|
if (/^[A-Za-z0-9_\-./+=:,@%^]+$/.test(word))
|
|
762
911
|
return word;
|
|
@@ -835,6 +984,10 @@ function allowRewrite(updatedInput) {
|
|
|
835
984
|
}
|
|
836
985
|
function resolveTrash(deps) {
|
|
837
986
|
try {
|
|
987
|
+
if (deps.inspectTrash) {
|
|
988
|
+
const inspection = deps.inspectTrash();
|
|
989
|
+
return inspection.ok ? { path: inspection.path, error: null } : { path: null, error: `${inspection.code}: ${BINARY_FAILURES[inspection.code]}` };
|
|
990
|
+
}
|
|
838
991
|
return { path: deps.findTrash(), error: null };
|
|
839
992
|
} catch (cause) {
|
|
840
993
|
return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
|
|
@@ -899,26 +1052,63 @@ function evaluate(input, deps) {
|
|
|
899
1052
|
if (false) {}
|
|
900
1053
|
|
|
901
1054
|
// hooks/hook-workspace-repos-guard/src/hook.ts
|
|
1055
|
+
import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
902
1056
|
import { homedir } from "os";
|
|
903
|
-
import { isAbsolute as
|
|
1057
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, normalize as normalize2, relative, resolve as resolve3, sep as sep2 } from "path";
|
|
904
1058
|
var FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
905
1059
|
var PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
906
1060
|
var DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
|
|
907
1061
|
var RULE = "workspace-repos-guard";
|
|
908
1062
|
function reposRoot(home) {
|
|
909
|
-
return
|
|
1063
|
+
return join3(home, ".hasna", "repos", "clones");
|
|
910
1064
|
}
|
|
911
1065
|
function legacyRoot(home) {
|
|
912
|
-
return
|
|
1066
|
+
return join3(home, "workspace", "repos");
|
|
913
1067
|
}
|
|
914
1068
|
function protectedRoots(home) {
|
|
915
1069
|
return [reposRoot(home), legacyRoot(home)];
|
|
916
1070
|
}
|
|
917
|
-
function
|
|
1071
|
+
function allowedOrgsFile(home) {
|
|
1072
|
+
return join3(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
|
|
1073
|
+
}
|
|
1074
|
+
var ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
|
1075
|
+
function ownedAndClosed(stat) {
|
|
1076
|
+
const uid = process.getuid?.();
|
|
1077
|
+
return uid !== undefined && (stat.uid === 0 || stat.uid === uid) && (stat.mode & 18) === 0;
|
|
1078
|
+
}
|
|
1079
|
+
function readAllowedOrgsFile(home) {
|
|
1080
|
+
const file = allowedOrgsFile(home);
|
|
1081
|
+
let text;
|
|
1082
|
+
try {
|
|
1083
|
+
const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
1084
|
+
try {
|
|
1085
|
+
const stat = fstatSync2(fd);
|
|
1086
|
+
if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096)
|
|
1087
|
+
return null;
|
|
1088
|
+
text = readFileSync2(fd, "utf8");
|
|
1089
|
+
} finally {
|
|
1090
|
+
closeSync2(fd);
|
|
1091
|
+
}
|
|
1092
|
+
const top = normalize2(home);
|
|
1093
|
+
for (let dir = dirname2(file);dir !== top; dir = dirname2(dir)) {
|
|
1094
|
+
const stat = lstatSync2(dir);
|
|
1095
|
+
if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname2(dir))
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
if (!ownedAndClosed(statSync2(top)))
|
|
1099
|
+
return null;
|
|
1100
|
+
} catch {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
const names = text.replace(/#[^\n]*/g, "").split(/[\s,]+/).filter(Boolean);
|
|
1104
|
+
return names.length > 0 && names.every((name) => ORG_NAME.test(name)) ? new Set(names) : null;
|
|
1105
|
+
}
|
|
1106
|
+
function resolveAllowedOrgs(env = process.env, home = homedir()) {
|
|
918
1107
|
const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
919
|
-
if (typeof raw
|
|
920
|
-
return new Set(
|
|
921
|
-
|
|
1108
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
1109
|
+
return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
1110
|
+
}
|
|
1111
|
+
return readAllowedOrgsFile(home) ?? new Set(DEFAULT_ORGS);
|
|
922
1112
|
}
|
|
923
1113
|
function classifyPath(target, root, orgs, op) {
|
|
924
1114
|
if (op === "read")
|
|
@@ -929,7 +1119,7 @@ function classifyPath(target, root, orgs, op) {
|
|
|
929
1119
|
return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
|
|
930
1120
|
return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
|
|
931
1121
|
}
|
|
932
|
-
if (rel.startsWith(`..${sep2}`) || rel === ".." ||
|
|
1122
|
+
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute3(rel)) {
|
|
933
1123
|
return { blocked: false };
|
|
934
1124
|
}
|
|
935
1125
|
const segments = rel.split(sep2).filter(Boolean);
|
|
@@ -952,6 +1142,11 @@ function classifyPath(target, root, orgs, op) {
|
|
|
952
1142
|
return { blocked: false };
|
|
953
1143
|
}
|
|
954
1144
|
var WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
|
|
1145
|
+
var FD_DUPLICATION = /\d*[<>]&\s*(?:\d+|-)(?=$|[\s;&|)])/g;
|
|
1146
|
+
var OUTPUT_REDIRECT = /(?:\d+|&)?(?:>>|>\||>&|<>|>)\s*([^\s;&|<>()\x60]*)/g;
|
|
1147
|
+
function withoutFdDuplication(segment) {
|
|
1148
|
+
return segment.replace(FD_DUPLICATION, " ");
|
|
1149
|
+
}
|
|
955
1150
|
function segmentOperation(segment) {
|
|
956
1151
|
const trimmed = segment.trim();
|
|
957
1152
|
if (!trimmed)
|
|
@@ -980,7 +1175,7 @@ function segmentOperation(segment) {
|
|
|
980
1175
|
} else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
|
|
981
1176
|
return "write";
|
|
982
1177
|
}
|
|
983
|
-
if (/(?:^|[;&|])\s*echo\b/.test(trimmed) ||
|
|
1178
|
+
if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || />/.test(withoutFdDuplication(trimmed)))
|
|
984
1179
|
return "write";
|
|
985
1180
|
return "read";
|
|
986
1181
|
}
|
|
@@ -994,13 +1189,13 @@ function expandHomeSpelling2(token, home) {
|
|
|
994
1189
|
if (body === "~")
|
|
995
1190
|
return home;
|
|
996
1191
|
if (body.startsWith("~/"))
|
|
997
|
-
return
|
|
1192
|
+
return join3(home, body.slice(2));
|
|
998
1193
|
if (body === "$HOME" || body === "${HOME}")
|
|
999
1194
|
return home;
|
|
1000
1195
|
if (body.startsWith("$HOME/"))
|
|
1001
|
-
return
|
|
1196
|
+
return join3(home, body.slice("$HOME/".length));
|
|
1002
1197
|
if (body.startsWith("${HOME}/"))
|
|
1003
|
-
return
|
|
1198
|
+
return join3(home, body.slice("${HOME}/".length));
|
|
1004
1199
|
const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
|
|
1005
1200
|
if (homeMatch)
|
|
1006
1201
|
return home + "/" + body.slice(homeMatch[0].length);
|
|
@@ -1017,22 +1212,51 @@ function unwrapSegment(segment) {
|
|
|
1017
1212
|
function regexEscape(text) {
|
|
1018
1213
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1019
1214
|
}
|
|
1215
|
+
function cdDestinations(operand, bases, home) {
|
|
1216
|
+
if (operand === undefined)
|
|
1217
|
+
return [home];
|
|
1218
|
+
let word = expandHomeSpelling2(operand, home);
|
|
1219
|
+
const quoted = word.match(/^(['"])(.*)\1$/);
|
|
1220
|
+
if (quoted)
|
|
1221
|
+
word = quoted[2];
|
|
1222
|
+
if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word))
|
|
1223
|
+
return null;
|
|
1224
|
+
return [...new Set(bases.map((base) => resolve3(base, word)))];
|
|
1225
|
+
}
|
|
1226
|
+
var REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
|
|
1020
1227
|
function bashTargets(command, home, cwd) {
|
|
1021
1228
|
if (!command)
|
|
1022
1229
|
return [];
|
|
1023
1230
|
const roots = protectedRoots(home);
|
|
1024
|
-
const
|
|
1231
|
+
const underRoot = (path) => roots.some((root) => path === root || path.startsWith(`${root}${sep2}`));
|
|
1232
|
+
const parts = command.split(/(\s*&&\s*|\s*\|\|\s*|;\s*|\n+)/);
|
|
1025
1233
|
const targets = [];
|
|
1026
|
-
let
|
|
1027
|
-
|
|
1234
|
+
let current = [cwd];
|
|
1235
|
+
let reachable = new Set([cwd]);
|
|
1236
|
+
let certain = true;
|
|
1237
|
+
const subshells = [];
|
|
1238
|
+
for (let index = 0;index < parts.length; index += 2) {
|
|
1239
|
+
const separator = index === 0 ? "" : parts[index - 1].trim();
|
|
1240
|
+
if (separator === "||")
|
|
1241
|
+
certain = false;
|
|
1242
|
+
else if (separator !== "&&")
|
|
1243
|
+
certain = true;
|
|
1244
|
+
if (separator !== "&&" || !certain)
|
|
1245
|
+
current = [...reachable];
|
|
1246
|
+
const rawSegment = parts[index];
|
|
1247
|
+
const opens = (rawSegment.match(/^[\s({]*/)?.[0].match(/\(/g) ?? []).length;
|
|
1248
|
+
const closes = (rawSegment.match(/[\s)}]*$/)?.[0].match(/\)/g) ?? []).length;
|
|
1249
|
+
for (let i = 0;i < opens; i++)
|
|
1250
|
+
subshells.push({ current: [...current], reachable: new Set(reachable), certain });
|
|
1028
1251
|
const segment = unwrapSegment(rawSegment);
|
|
1029
1252
|
const op = segmentOperation(segment);
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1253
|
+
const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
|
|
1254
|
+
const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
|
|
1255
|
+
if (embeddedCd) {
|
|
1256
|
+
for (const dir of cdDestinations(embeddedCd[1], current, home) ?? [])
|
|
1257
|
+
reachable.add(dir);
|
|
1258
|
+
certain = false;
|
|
1259
|
+
current = [...reachable];
|
|
1036
1260
|
}
|
|
1037
1261
|
const homeLiteral = regexEscape(home);
|
|
1038
1262
|
const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
|
|
@@ -1046,14 +1270,35 @@ function bashTargets(command, home, cwd) {
|
|
|
1046
1270
|
targets.push({ path: normalize2(expanded).replace(/\/+$/, ""), op });
|
|
1047
1271
|
}
|
|
1048
1272
|
if (!foundExplicit && (op === "delete" || op === "write")) {
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1273
|
+
const operands = [];
|
|
1274
|
+
const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand) => {
|
|
1275
|
+
if (operand)
|
|
1276
|
+
operands.push(operand);
|
|
1277
|
+
return " ";
|
|
1278
|
+
});
|
|
1279
|
+
const commandOp = segmentOperation(commandPart);
|
|
1280
|
+
const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
|
|
1281
|
+
for (const base of current.filter(underRoot)) {
|
|
1282
|
+
for (const operand of operands) {
|
|
1283
|
+
targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
|
|
1054
1284
|
}
|
|
1285
|
+
if (relMatch)
|
|
1286
|
+
targets.push({ path: normalize2(resolve3(base, relMatch[1])), op: commandOp });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (plainCd) {
|
|
1290
|
+
const next = cdDestinations(plainCd[1], current, home);
|
|
1291
|
+
if (next) {
|
|
1292
|
+
for (const dir of next)
|
|
1293
|
+
reachable.add(dir);
|
|
1294
|
+
if (certain)
|
|
1295
|
+
current = next;
|
|
1296
|
+
} else {
|
|
1297
|
+
certain = false;
|
|
1055
1298
|
}
|
|
1056
1299
|
}
|
|
1300
|
+
for (let i = 0;i < closes && subshells.length > 0; i++)
|
|
1301
|
+
({ current, reachable, certain } = subshells.pop());
|
|
1057
1302
|
}
|
|
1058
1303
|
return targets;
|
|
1059
1304
|
}
|
|
@@ -1063,18 +1308,18 @@ function patchTargets(patch, cwd) {
|
|
|
1063
1308
|
let m;
|
|
1064
1309
|
while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
|
|
1065
1310
|
const op = m[1] === "Delete File" ? "delete" : "write";
|
|
1066
|
-
targets.push({ path: normalize2(
|
|
1311
|
+
targets.push({ path: normalize2(resolve3(cwd, m[2])), op });
|
|
1067
1312
|
}
|
|
1068
1313
|
return targets;
|
|
1069
1314
|
}
|
|
1070
|
-
function evaluate2(input) {
|
|
1315
|
+
function evaluate2(input, options = {}) {
|
|
1071
1316
|
const warnings = [];
|
|
1072
1317
|
if (input.hook_event_name !== "PreToolUse")
|
|
1073
1318
|
return { output: { continue: true }, warnings };
|
|
1074
1319
|
const tool = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
1075
|
-
const home = homedir();
|
|
1320
|
+
const home = options.home ?? homedir();
|
|
1076
1321
|
const roots = protectedRoots(home);
|
|
1077
|
-
const orgs = resolveAllowedOrgs();
|
|
1322
|
+
const orgs = resolveAllowedOrgs(process.env, home);
|
|
1078
1323
|
if (tool === "Bash") {
|
|
1079
1324
|
const command = getCommand(input);
|
|
1080
1325
|
if (!command)
|
|
@@ -1114,7 +1359,7 @@ function evaluate2(input) {
|
|
|
1114
1359
|
if (!raw)
|
|
1115
1360
|
return { output: { continue: true }, warnings };
|
|
1116
1361
|
const expanded = expandHomeSpelling2(raw, home);
|
|
1117
|
-
const target =
|
|
1362
|
+
const target = isAbsolute3(expanded) ? normalize2(expanded) : resolve3(cwd, expanded);
|
|
1118
1363
|
for (const root of roots) {
|
|
1119
1364
|
const verdict = classifyPath(target, root, orgs, "write");
|
|
1120
1365
|
if (verdict.blocked) {
|
|
@@ -1130,7 +1375,7 @@ var name = process.argv[1];
|
|
|
1130
1375
|
if (name !== "trash-guard" && name !== "workspace-repos-guard")
|
|
1131
1376
|
throw new Error("Invalid safety capability");
|
|
1132
1377
|
var input = JSON.parse(await Bun.stdin.text());
|
|
1133
|
-
if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string" || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input) || typeof input.cwd !== "string" || !
|
|
1378
|
+
if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string" || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input) || typeof input.cwd !== "string" || !isAbsolute4(input.cwd))
|
|
1134
1379
|
throw new Error("Invalid safety input");
|
|
1135
1380
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
|
|
1136
1381
|
throw new Error("Invalid shell input");
|
|
@@ -1144,5 +1389,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
|
|
|
1144
1389
|
}
|
|
1145
1390
|
var repos = evaluate2(input).output;
|
|
1146
1391
|
var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
|
|
1147
|
-
var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary }) : { continue: true };
|
|
1392
|
+
var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
|
|
1148
1393
|
process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
|
package/bin/serve.js
CHANGED
|
@@ -676,7 +676,7 @@ function getHook(name) {
|
|
|
676
676
|
import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
677
677
|
import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
|
|
678
678
|
|
|
679
|
-
//
|
|
679
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
680
680
|
var exports_external = {};
|
|
681
681
|
__export(exports_external, {
|
|
682
682
|
void: () => voidType,
|
|
@@ -788,7 +788,7 @@ __export(exports_external, {
|
|
|
788
788
|
BRAND: () => BRAND
|
|
789
789
|
});
|
|
790
790
|
|
|
791
|
-
//
|
|
791
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
|
|
792
792
|
var util;
|
|
793
793
|
(function(util2) {
|
|
794
794
|
util2.assertEqual = (_) => {};
|
|
@@ -919,7 +919,7 @@ var getParsedType = (data) => {
|
|
|
919
919
|
}
|
|
920
920
|
};
|
|
921
921
|
|
|
922
|
-
//
|
|
922
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/ZodError.js
|
|
923
923
|
var ZodIssueCode = util.arrayToEnum([
|
|
924
924
|
"invalid_type",
|
|
925
925
|
"invalid_literal",
|
|
@@ -1038,7 +1038,7 @@ ZodError.create = (issues) => {
|
|
|
1038
1038
|
return error;
|
|
1039
1039
|
};
|
|
1040
1040
|
|
|
1041
|
-
//
|
|
1041
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/locales/en.js
|
|
1042
1042
|
var errorMap = (issue, _ctx) => {
|
|
1043
1043
|
let message;
|
|
1044
1044
|
switch (issue.code) {
|
|
@@ -1141,7 +1141,7 @@ var errorMap = (issue, _ctx) => {
|
|
|
1141
1141
|
};
|
|
1142
1142
|
var en_default = errorMap;
|
|
1143
1143
|
|
|
1144
|
-
//
|
|
1144
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/errors.js
|
|
1145
1145
|
var overrideErrorMap = en_default;
|
|
1146
1146
|
function setErrorMap(map) {
|
|
1147
1147
|
overrideErrorMap = map;
|
|
@@ -1149,7 +1149,7 @@ function setErrorMap(map) {
|
|
|
1149
1149
|
function getErrorMap() {
|
|
1150
1150
|
return overrideErrorMap;
|
|
1151
1151
|
}
|
|
1152
|
-
//
|
|
1152
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
|
|
1153
1153
|
var makeIssue = (params) => {
|
|
1154
1154
|
const { data, path, errorMaps, issueData } = params;
|
|
1155
1155
|
const fullPath = [...path, ...issueData.path || []];
|
|
@@ -1255,14 +1255,14 @@ var isAborted = (x) => x.status === "aborted";
|
|
|
1255
1255
|
var isDirty = (x) => x.status === "dirty";
|
|
1256
1256
|
var isValid = (x) => x.status === "valid";
|
|
1257
1257
|
var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise;
|
|
1258
|
-
//
|
|
1258
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js
|
|
1259
1259
|
var errorUtil;
|
|
1260
1260
|
(function(errorUtil2) {
|
|
1261
1261
|
errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {};
|
|
1262
1262
|
errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message;
|
|
1263
1263
|
})(errorUtil || (errorUtil = {}));
|
|
1264
1264
|
|
|
1265
|
-
//
|
|
1265
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/types.js
|
|
1266
1266
|
class ParseInputLazyPath {
|
|
1267
1267
|
constructor(parent, value, path, key) {
|
|
1268
1268
|
this._cachedPath = [];
|
|
@@ -5362,7 +5362,7 @@ import { readFileSync as readFileSync5 } from "fs";
|
|
|
5362
5362
|
import { dirname as dirname4, join as join8 } from "path";
|
|
5363
5363
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
5364
5364
|
|
|
5365
|
-
//
|
|
5365
|
+
// ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/node_modules/@hasna/contracts/dist/client/transport.js
|
|
5366
5366
|
import { spawnSync } from "child_process";
|
|
5367
5367
|
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync22 } from "fs";
|
|
5368
5368
|
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|