@hasna/hooks 0.10.3 → 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 +4 -1
- package/bin/index.js +553 -111
- package/bin/native-safety-entry.js +220 -67
- package/dist/index.js +4 -1
- 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/hooks/hook-trash-guard/src/binary.ts +158 -0
- package/hooks/hook-trash-guard/src/hook.ts +10 -40
- 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,24 +1052,24 @@ function evaluate(input, deps) {
|
|
|
899
1052
|
if (false) {}
|
|
900
1053
|
|
|
901
1054
|
// hooks/hook-workspace-repos-guard/src/hook.ts
|
|
902
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
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";
|
|
903
1056
|
import { homedir } from "os";
|
|
904
|
-
import { dirname as dirname2, 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";
|
|
905
1058
|
var FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
906
1059
|
var PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
907
1060
|
var DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
|
|
908
1061
|
var RULE = "workspace-repos-guard";
|
|
909
1062
|
function reposRoot(home) {
|
|
910
|
-
return
|
|
1063
|
+
return join3(home, ".hasna", "repos", "clones");
|
|
911
1064
|
}
|
|
912
1065
|
function legacyRoot(home) {
|
|
913
|
-
return
|
|
1066
|
+
return join3(home, "workspace", "repos");
|
|
914
1067
|
}
|
|
915
1068
|
function protectedRoots(home) {
|
|
916
1069
|
return [reposRoot(home), legacyRoot(home)];
|
|
917
1070
|
}
|
|
918
1071
|
function allowedOrgsFile(home) {
|
|
919
|
-
return
|
|
1072
|
+
return join3(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
|
|
920
1073
|
}
|
|
921
1074
|
var ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
|
922
1075
|
function ownedAndClosed(stat) {
|
|
@@ -927,18 +1080,18 @@ function readAllowedOrgsFile(home) {
|
|
|
927
1080
|
const file = allowedOrgsFile(home);
|
|
928
1081
|
let text;
|
|
929
1082
|
try {
|
|
930
|
-
const fd =
|
|
1083
|
+
const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
931
1084
|
try {
|
|
932
|
-
const stat =
|
|
1085
|
+
const stat = fstatSync2(fd);
|
|
933
1086
|
if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096)
|
|
934
1087
|
return null;
|
|
935
1088
|
text = readFileSync2(fd, "utf8");
|
|
936
1089
|
} finally {
|
|
937
|
-
|
|
1090
|
+
closeSync2(fd);
|
|
938
1091
|
}
|
|
939
1092
|
const top = normalize2(home);
|
|
940
1093
|
for (let dir = dirname2(file);dir !== top; dir = dirname2(dir)) {
|
|
941
|
-
const stat =
|
|
1094
|
+
const stat = lstatSync2(dir);
|
|
942
1095
|
if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname2(dir))
|
|
943
1096
|
return null;
|
|
944
1097
|
}
|
|
@@ -966,7 +1119,7 @@ function classifyPath(target, root, orgs, op) {
|
|
|
966
1119
|
return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
|
|
967
1120
|
return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
|
|
968
1121
|
}
|
|
969
|
-
if (rel.startsWith(`..${sep2}`) || rel === ".." ||
|
|
1122
|
+
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute3(rel)) {
|
|
970
1123
|
return { blocked: false };
|
|
971
1124
|
}
|
|
972
1125
|
const segments = rel.split(sep2).filter(Boolean);
|
|
@@ -1036,13 +1189,13 @@ function expandHomeSpelling2(token, home) {
|
|
|
1036
1189
|
if (body === "~")
|
|
1037
1190
|
return home;
|
|
1038
1191
|
if (body.startsWith("~/"))
|
|
1039
|
-
return
|
|
1192
|
+
return join3(home, body.slice(2));
|
|
1040
1193
|
if (body === "$HOME" || body === "${HOME}")
|
|
1041
1194
|
return home;
|
|
1042
1195
|
if (body.startsWith("$HOME/"))
|
|
1043
|
-
return
|
|
1196
|
+
return join3(home, body.slice("$HOME/".length));
|
|
1044
1197
|
if (body.startsWith("${HOME}/"))
|
|
1045
|
-
return
|
|
1198
|
+
return join3(home, body.slice("${HOME}/".length));
|
|
1046
1199
|
const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
|
|
1047
1200
|
if (homeMatch)
|
|
1048
1201
|
return home + "/" + body.slice(homeMatch[0].length);
|
|
@@ -1068,7 +1221,7 @@ function cdDestinations(operand, bases, home) {
|
|
|
1068
1221
|
word = quoted[2];
|
|
1069
1222
|
if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word))
|
|
1070
1223
|
return null;
|
|
1071
|
-
return [...new Set(bases.map((base) =>
|
|
1224
|
+
return [...new Set(bases.map((base) => resolve3(base, word)))];
|
|
1072
1225
|
}
|
|
1073
1226
|
var REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
|
|
1074
1227
|
function bashTargets(command, home, cwd) {
|
|
@@ -1127,10 +1280,10 @@ function bashTargets(command, home, cwd) {
|
|
|
1127
1280
|
const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
|
|
1128
1281
|
for (const base of current.filter(underRoot)) {
|
|
1129
1282
|
for (const operand of operands) {
|
|
1130
|
-
targets.push({ path: normalize2(
|
|
1283
|
+
targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
|
|
1131
1284
|
}
|
|
1132
1285
|
if (relMatch)
|
|
1133
|
-
targets.push({ path: normalize2(
|
|
1286
|
+
targets.push({ path: normalize2(resolve3(base, relMatch[1])), op: commandOp });
|
|
1134
1287
|
}
|
|
1135
1288
|
}
|
|
1136
1289
|
if (plainCd) {
|
|
@@ -1155,7 +1308,7 @@ function patchTargets(patch, cwd) {
|
|
|
1155
1308
|
let m;
|
|
1156
1309
|
while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
|
|
1157
1310
|
const op = m[1] === "Delete File" ? "delete" : "write";
|
|
1158
|
-
targets.push({ path: normalize2(
|
|
1311
|
+
targets.push({ path: normalize2(resolve3(cwd, m[2])), op });
|
|
1159
1312
|
}
|
|
1160
1313
|
return targets;
|
|
1161
1314
|
}
|
|
@@ -1206,7 +1359,7 @@ function evaluate2(input, options = {}) {
|
|
|
1206
1359
|
if (!raw)
|
|
1207
1360
|
return { output: { continue: true }, warnings };
|
|
1208
1361
|
const expanded = expandHomeSpelling2(raw, home);
|
|
1209
|
-
const target =
|
|
1362
|
+
const target = isAbsolute3(expanded) ? normalize2(expanded) : resolve3(cwd, expanded);
|
|
1210
1363
|
for (const root of roots) {
|
|
1211
1364
|
const verdict = classifyPath(target, root, orgs, "write");
|
|
1212
1365
|
if (verdict.blocked) {
|
|
@@ -1222,7 +1375,7 @@ var name = process.argv[1];
|
|
|
1222
1375
|
if (name !== "trash-guard" && name !== "workspace-repos-guard")
|
|
1223
1376
|
throw new Error("Invalid safety capability");
|
|
1224
1377
|
var input = JSON.parse(await Bun.stdin.text());
|
|
1225
|
-
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))
|
|
1226
1379
|
throw new Error("Invalid safety input");
|
|
1227
1380
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
|
|
1228
1381
|
throw new Error("Invalid shell input");
|
|
@@ -1236,5 +1389,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
|
|
|
1236
1389
|
}
|
|
1237
1390
|
var repos = evaluate2(input).output;
|
|
1238
1391
|
var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
|
|
1239
|
-
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 };
|
|
1240
1393
|
process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
|
package/dist/index.js
CHANGED
|
@@ -7719,6 +7719,9 @@ function buildNativeSafetyCommand(binding) {
|
|
|
7719
7719
|
return prefix + [encoded, binding.runtime, binding.home, binding.path].map(quote).join(" ");
|
|
7720
7720
|
}
|
|
7721
7721
|
function nativeSafetyRegistration(command) {
|
|
7722
|
+
return nativeSafetyCommandBinding(command)?.name;
|
|
7723
|
+
}
|
|
7724
|
+
function nativeSafetyCommandBinding(command) {
|
|
7722
7725
|
if (!command.startsWith(prefix))
|
|
7723
7726
|
return;
|
|
7724
7727
|
const encoded = command.slice(prefix.length).match(/^'([A-Za-z0-9_-]+)' /)?.[1];
|
|
@@ -7726,7 +7729,7 @@ function nativeSafetyRegistration(command) {
|
|
|
7726
7729
|
return;
|
|
7727
7730
|
try {
|
|
7728
7731
|
const binding = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
7729
|
-
return buildNativeSafetyCommand(binding) === command ? binding
|
|
7732
|
+
return buildNativeSafetyCommand(binding) === command ? binding : undefined;
|
|
7730
7733
|
} catch {
|
|
7731
7734
|
return;
|
|
7732
7735
|
}
|
|
@@ -11,5 +11,6 @@ export interface NativeSafetyBinding {
|
|
|
11
11
|
export declare function isNativeSafetyName(name: string): name is NativeSafetyName;
|
|
12
12
|
export declare function buildNativeSafetyCommand(binding: NativeSafetyBinding): string;
|
|
13
13
|
export declare function nativeSafetyRegistration(command: string): NativeSafetyName | undefined;
|
|
14
|
+
export declare function nativeSafetyCommandBinding(command: string): NativeSafetyBinding | undefined;
|
|
14
15
|
/** Built-in capability only: no registry, custom source, profile or app store. */
|
|
15
16
|
export declare function installedNativeSafetyCommand(name: NativeSafetyName): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare class NativeSafetyError extends Error {
|
|
2
|
+
readonly code: "binding_missing" | "binding_invalid" | "binding_changed" | "binding_busy" | "guard_failed" | "input_invalid";
|
|
3
|
+
readonly reason?: string | undefined;
|
|
4
|
+
constructor(code: "binding_missing" | "binding_invalid" | "binding_changed" | "binding_busy" | "guard_failed" | "input_invalid", reason?: string | undefined);
|
|
5
|
+
}
|
|
6
|
+
export type NativeSafetyEvent = {
|
|
7
|
+
hook_event_name: "PreToolUse";
|
|
8
|
+
tool_name: "Bash" | "apply_patch";
|
|
9
|
+
cwd: string;
|
|
10
|
+
session_id?: string;
|
|
11
|
+
tool_input: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
export type NativeSafetyDecision = {
|
|
14
|
+
decision: "continue";
|
|
15
|
+
} | {
|
|
16
|
+
decision: "deny";
|
|
17
|
+
reason: string;
|
|
18
|
+
} | {
|
|
19
|
+
decision: "rewrite";
|
|
20
|
+
command: string;
|
|
21
|
+
};
|
|
22
|
+
export type NativeSafetyRecord = {
|
|
23
|
+
schema: "hasna.hooks.native-safety.v1";
|
|
24
|
+
harness: "sumi";
|
|
25
|
+
command: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function readNativeSafetyRegistration(home?: string): {
|
|
28
|
+
record: NativeSafetyRecord;
|
|
29
|
+
sha256: string;
|
|
30
|
+
path: string;
|
|
31
|
+
};
|
|
32
|
+
/** Register only the bundled composite guard. Updates preserve the exact prior
|
|
33
|
+
* record and require its digest. A lock coordinates package-owned writers; an
|
|
34
|
+
* unexpected preimage or a retained interrupted lock requires reconciliation. */
|
|
35
|
+
export declare function registerNativeSafety(options?: {
|
|
36
|
+
home?: string;
|
|
37
|
+
expectedSHA256?: string;
|
|
38
|
+
command?: string;
|
|
39
|
+
}): {
|
|
40
|
+
ok: true;
|
|
41
|
+
changed: boolean;
|
|
42
|
+
path: string;
|
|
43
|
+
sha256: string;
|
|
44
|
+
};
|
|
45
|
+
/** A bounded supervisor subprocess evaluates data only; it never runs the
|
|
46
|
+
* requested shell command. AbortSignal targets this newly created child. */
|
|
47
|
+
export declare function evaluateNativeSafety(event: NativeSafetyEvent, options?: {
|
|
48
|
+
home?: string;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}): Promise<NativeSafetyDecision>;
|
|
51
|
+
/** Classify a synthetic removal through this exact supervisor without executing
|
|
52
|
+
* the returned command or creating a file. A harmless ':' would not test whether
|
|
53
|
+
* the saved PATH can actually resolve Trash. */
|
|
54
|
+
export declare function verifyNativeSafetyCommand(command: string, home?: string): Promise<void>;
|
|
55
|
+
export declare function verifyNativeSafetyRegistration(home?: string): Promise<{
|
|
56
|
+
path: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
}>;
|