@hasna/hooks 0.10.3 → 0.10.5
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 +55 -0
- package/bin/hooks-mcp.js +4 -1
- package/bin/index.js +589 -111
- package/bin/native-safety-entry.js +266 -70
- package/dist/index.js +4 -1
- package/dist/lib/native-safety.d.ts +1 -0
- package/dist/native-safety.d.ts +88 -0
- package/dist/native-safety.js +401 -0
- package/hooks/hook-trash-guard/src/binary.ts +158 -0
- package/hooks/hook-trash-guard/src/hook.ts +38 -43
- package/hooks/native-safety-entry.ts +10 -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,49 +906,26 @@ 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;
|
|
763
912
|
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
764
913
|
}
|
|
765
|
-
function rewriteCommand(command, hits, trashPath) {
|
|
766
|
-
const replacement =
|
|
914
|
+
function rewriteCommand(command, hits, trashPath, execution) {
|
|
915
|
+
const replacement = execution ? [
|
|
916
|
+
"/usr/bin/env",
|
|
917
|
+
"-i",
|
|
918
|
+
`HOME=${execution.home}`,
|
|
919
|
+
`PATH=${execution.path}`,
|
|
920
|
+
`HASNA_TRASH_AGENT=${execution.agent}`,
|
|
921
|
+
execution.runtime,
|
|
922
|
+
"--config=/dev/null",
|
|
923
|
+
"--no-env-file",
|
|
924
|
+
"--no-macros",
|
|
925
|
+
"--no-install",
|
|
926
|
+
trashPath,
|
|
927
|
+
"guard"
|
|
928
|
+
].map(shellQuoteWord).join(" ") : `${shellQuoteWord(trashPath)} guard`;
|
|
767
929
|
const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
|
|
768
930
|
let out = command;
|
|
769
931
|
for (const hit of ordered) {
|
|
@@ -815,6 +977,20 @@ function updatedInputIsComplete(updated, original, hits, cwd, home) {
|
|
|
815
977
|
return false;
|
|
816
978
|
return rewriteIsComplete(original, updated.command, hits, cwd, home);
|
|
817
979
|
}
|
|
980
|
+
function changesExecutionEnvironment(command) {
|
|
981
|
+
const stateCommands = new Set(["export", "set", "unset", "readonly", "declare", "typeset", "local", "alias", "unalias", "read", "readarray", "mapfile", "getopts", "trap", "enable", "function", "env", "sudo", "doas", "stdbuf"]);
|
|
982
|
+
const parsed = lexCommand(command);
|
|
983
|
+
return parsed.segments.some((segment) => {
|
|
984
|
+
const index = commandWordIndex(segment);
|
|
985
|
+
const prefix = segment.words.slice(0, index < 0 ? segment.words.length : index);
|
|
986
|
+
if (prefix.some((word2) => ASSIGNMENT_RE.test(decodeWord(word2.text)) || stateCommands.has(baseVerb(decodeWord(word2.text)))))
|
|
987
|
+
return true;
|
|
988
|
+
if (index < 0)
|
|
989
|
+
return false;
|
|
990
|
+
const word = segment.words[index];
|
|
991
|
+
return stateCommands.has(baseVerb(decodeWord(word.text))) || /^\s*\(\s*\)\s*\{/.test(command.slice(word.end));
|
|
992
|
+
});
|
|
993
|
+
}
|
|
818
994
|
function deny(reason) {
|
|
819
995
|
return {
|
|
820
996
|
hookSpecificOutput: {
|
|
@@ -835,6 +1011,10 @@ function allowRewrite(updatedInput) {
|
|
|
835
1011
|
}
|
|
836
1012
|
function resolveTrash(deps) {
|
|
837
1013
|
try {
|
|
1014
|
+
if (deps.inspectTrash) {
|
|
1015
|
+
const inspection = deps.inspectTrash();
|
|
1016
|
+
return inspection.ok ? { path: inspection.path, error: null } : { path: null, error: `${inspection.code}: ${BINARY_FAILURES[inspection.code]}` };
|
|
1017
|
+
}
|
|
838
1018
|
return { path: deps.findTrash(), error: null };
|
|
839
1019
|
} catch (cause) {
|
|
840
1020
|
return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
|
|
@@ -889,8 +1069,13 @@ function evaluate(input, deps) {
|
|
|
889
1069
|
return deny(ABSENT_REASON);
|
|
890
1070
|
}
|
|
891
1071
|
const trashPath = trash.path;
|
|
892
|
-
|
|
1072
|
+
if (deps.execution && changesExecutionEnvironment(command)) {
|
|
1073
|
+
return deny("[trash-guard] A deletion combined with shell environment changes cannot use the verified native execution context. Run the deletion as a separate plain rm command.");
|
|
1074
|
+
}
|
|
1075
|
+
const rewritten = rewriteCommand(command, scanned.rmHits, trashPath, deps.execution);
|
|
893
1076
|
const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
|
|
1077
|
+
if (deps.execution)
|
|
1078
|
+
updatedInput.hasna_execution = "hasna.hooks.execution.v1";
|
|
894
1079
|
if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
|
|
895
1080
|
return deny("[trash-guard] The rewrite of this command did not verify (the delete could not be redirected cleanly), so the command is refused rather than run unredirected. Re-run the delete as a plain `rm -- <path>` command.");
|
|
896
1081
|
}
|
|
@@ -899,24 +1084,24 @@ function evaluate(input, deps) {
|
|
|
899
1084
|
if (false) {}
|
|
900
1085
|
|
|
901
1086
|
// hooks/hook-workspace-repos-guard/src/hook.ts
|
|
902
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
1087
|
+
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
1088
|
import { homedir } from "os";
|
|
904
|
-
import { dirname as dirname2, isAbsolute as
|
|
1089
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, normalize as normalize2, relative, resolve as resolve3, sep as sep2 } from "path";
|
|
905
1090
|
var FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
906
1091
|
var PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
907
1092
|
var DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
|
|
908
1093
|
var RULE = "workspace-repos-guard";
|
|
909
1094
|
function reposRoot(home) {
|
|
910
|
-
return
|
|
1095
|
+
return join3(home, ".hasna", "repos", "clones");
|
|
911
1096
|
}
|
|
912
1097
|
function legacyRoot(home) {
|
|
913
|
-
return
|
|
1098
|
+
return join3(home, "workspace", "repos");
|
|
914
1099
|
}
|
|
915
1100
|
function protectedRoots(home) {
|
|
916
1101
|
return [reposRoot(home), legacyRoot(home)];
|
|
917
1102
|
}
|
|
918
1103
|
function allowedOrgsFile(home) {
|
|
919
|
-
return
|
|
1104
|
+
return join3(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
|
|
920
1105
|
}
|
|
921
1106
|
var ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
|
922
1107
|
function ownedAndClosed(stat) {
|
|
@@ -927,18 +1112,18 @@ function readAllowedOrgsFile(home) {
|
|
|
927
1112
|
const file = allowedOrgsFile(home);
|
|
928
1113
|
let text;
|
|
929
1114
|
try {
|
|
930
|
-
const fd =
|
|
1115
|
+
const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
931
1116
|
try {
|
|
932
|
-
const stat =
|
|
1117
|
+
const stat = fstatSync2(fd);
|
|
933
1118
|
if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096)
|
|
934
1119
|
return null;
|
|
935
1120
|
text = readFileSync2(fd, "utf8");
|
|
936
1121
|
} finally {
|
|
937
|
-
|
|
1122
|
+
closeSync2(fd);
|
|
938
1123
|
}
|
|
939
1124
|
const top = normalize2(home);
|
|
940
1125
|
for (let dir = dirname2(file);dir !== top; dir = dirname2(dir)) {
|
|
941
|
-
const stat =
|
|
1126
|
+
const stat = lstatSync2(dir);
|
|
942
1127
|
if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname2(dir))
|
|
943
1128
|
return null;
|
|
944
1129
|
}
|
|
@@ -966,7 +1151,7 @@ function classifyPath(target, root, orgs, op) {
|
|
|
966
1151
|
return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
|
|
967
1152
|
return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
|
|
968
1153
|
}
|
|
969
|
-
if (rel.startsWith(`..${sep2}`) || rel === ".." ||
|
|
1154
|
+
if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute3(rel)) {
|
|
970
1155
|
return { blocked: false };
|
|
971
1156
|
}
|
|
972
1157
|
const segments = rel.split(sep2).filter(Boolean);
|
|
@@ -1036,13 +1221,13 @@ function expandHomeSpelling2(token, home) {
|
|
|
1036
1221
|
if (body === "~")
|
|
1037
1222
|
return home;
|
|
1038
1223
|
if (body.startsWith("~/"))
|
|
1039
|
-
return
|
|
1224
|
+
return join3(home, body.slice(2));
|
|
1040
1225
|
if (body === "$HOME" || body === "${HOME}")
|
|
1041
1226
|
return home;
|
|
1042
1227
|
if (body.startsWith("$HOME/"))
|
|
1043
|
-
return
|
|
1228
|
+
return join3(home, body.slice("$HOME/".length));
|
|
1044
1229
|
if (body.startsWith("${HOME}/"))
|
|
1045
|
-
return
|
|
1230
|
+
return join3(home, body.slice("${HOME}/".length));
|
|
1046
1231
|
const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
|
|
1047
1232
|
if (homeMatch)
|
|
1048
1233
|
return home + "/" + body.slice(homeMatch[0].length);
|
|
@@ -1068,7 +1253,7 @@ function cdDestinations(operand, bases, home) {
|
|
|
1068
1253
|
word = quoted[2];
|
|
1069
1254
|
if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word))
|
|
1070
1255
|
return null;
|
|
1071
|
-
return [...new Set(bases.map((base) =>
|
|
1256
|
+
return [...new Set(bases.map((base) => resolve3(base, word)))];
|
|
1072
1257
|
}
|
|
1073
1258
|
var REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
|
|
1074
1259
|
function bashTargets(command, home, cwd) {
|
|
@@ -1127,10 +1312,10 @@ function bashTargets(command, home, cwd) {
|
|
|
1127
1312
|
const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
|
|
1128
1313
|
for (const base of current.filter(underRoot)) {
|
|
1129
1314
|
for (const operand of operands) {
|
|
1130
|
-
targets.push({ path: normalize2(
|
|
1315
|
+
targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
|
|
1131
1316
|
}
|
|
1132
1317
|
if (relMatch)
|
|
1133
|
-
targets.push({ path: normalize2(
|
|
1318
|
+
targets.push({ path: normalize2(resolve3(base, relMatch[1])), op: commandOp });
|
|
1134
1319
|
}
|
|
1135
1320
|
}
|
|
1136
1321
|
if (plainCd) {
|
|
@@ -1155,7 +1340,7 @@ function patchTargets(patch, cwd) {
|
|
|
1155
1340
|
let m;
|
|
1156
1341
|
while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
|
|
1157
1342
|
const op = m[1] === "Delete File" ? "delete" : "write";
|
|
1158
|
-
targets.push({ path: normalize2(
|
|
1343
|
+
targets.push({ path: normalize2(resolve3(cwd, m[2])), op });
|
|
1159
1344
|
}
|
|
1160
1345
|
return targets;
|
|
1161
1346
|
}
|
|
@@ -1206,7 +1391,7 @@ function evaluate2(input, options = {}) {
|
|
|
1206
1391
|
if (!raw)
|
|
1207
1392
|
return { output: { continue: true }, warnings };
|
|
1208
1393
|
const expanded = expandHomeSpelling2(raw, home);
|
|
1209
|
-
const target =
|
|
1394
|
+
const target = isAbsolute3(expanded) ? normalize2(expanded) : resolve3(cwd, expanded);
|
|
1210
1395
|
for (const root of roots) {
|
|
1211
1396
|
const verdict = classifyPath(target, root, orgs, "write");
|
|
1212
1397
|
if (verdict.blocked) {
|
|
@@ -1222,10 +1407,21 @@ var name = process.argv[1];
|
|
|
1222
1407
|
if (name !== "trash-guard" && name !== "workspace-repos-guard")
|
|
1223
1408
|
throw new Error("Invalid safety capability");
|
|
1224
1409
|
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" || !
|
|
1410
|
+
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
1411
|
throw new Error("Invalid safety input");
|
|
1227
1412
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
|
|
1228
1413
|
throw new Error("Invalid shell input");
|
|
1414
|
+
var requestedExecution = input.hasna_execution;
|
|
1415
|
+
if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema" || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent)))
|
|
1416
|
+
throw new Error("Invalid execution capability");
|
|
1417
|
+
var execution = requestedExecution === undefined ? undefined : {
|
|
1418
|
+
runtime: process.execPath,
|
|
1419
|
+
home: homedir2(),
|
|
1420
|
+
path: process.env.PATH,
|
|
1421
|
+
agent: requestedExecution.agent
|
|
1422
|
+
};
|
|
1423
|
+
if (execution && (!isAbsolute4(execution.runtime) || !execution.path))
|
|
1424
|
+
throw new Error("Invalid bound execution context");
|
|
1229
1425
|
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
|
|
1230
1426
|
if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch)
|
|
1231
1427
|
throw new Error("Conflicting patch aliases");
|
|
@@ -1236,5 +1432,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
|
|
|
1236
1432
|
}
|
|
1237
1433
|
var repos = evaluate2(input).output;
|
|
1238
1434
|
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 };
|
|
1435
|
+
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, execution }) : { continue: true };
|
|
1240
1436
|
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,88 @@
|
|
|
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 NativeSafetyExecutionDecision = Exclude<NativeSafetyDecision, {
|
|
23
|
+
decision: "rewrite";
|
|
24
|
+
}> | {
|
|
25
|
+
decision: "rewrite";
|
|
26
|
+
execution: {
|
|
27
|
+
command: string;
|
|
28
|
+
shell: "/bin/sh";
|
|
29
|
+
cwd: string;
|
|
30
|
+
env: {
|
|
31
|
+
HOME: string;
|
|
32
|
+
PATH: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export type NativeSafetyRecord = {
|
|
37
|
+
schema: "hasna.hooks.native-safety.v1";
|
|
38
|
+
harness: "sumi";
|
|
39
|
+
command: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function readNativeSafetyRegistration(home?: string): {
|
|
42
|
+
record: NativeSafetyRecord;
|
|
43
|
+
sha256: string;
|
|
44
|
+
path: string;
|
|
45
|
+
};
|
|
46
|
+
/** Register only the bundled composite guard. Updates preserve the exact prior
|
|
47
|
+
* record and require its digest. A lock coordinates package-owned writers; an
|
|
48
|
+
* unexpected preimage or a retained interrupted lock requires reconciliation. */
|
|
49
|
+
export declare function registerNativeSafety(options?: {
|
|
50
|
+
home?: string;
|
|
51
|
+
expectedSHA256?: string;
|
|
52
|
+
command?: string;
|
|
53
|
+
}): {
|
|
54
|
+
ok: true;
|
|
55
|
+
changed: boolean;
|
|
56
|
+
path: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
};
|
|
59
|
+
/** A bounded supervisor subprocess evaluates data only; it never runs the
|
|
60
|
+
* requested shell command. AbortSignal targets this newly created child. */
|
|
61
|
+
export declare function evaluateNativeSafety(event: NativeSafetyEvent, options?: {
|
|
62
|
+
home?: string;
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
}): Promise<NativeSafetyDecision>;
|
|
65
|
+
/** Native tool dispatch consumes this entire context, with environment inheritance
|
|
66
|
+
* disabled. The worker pins each Trash invocation to its verified Bun runtime,
|
|
67
|
+
* ignoring caller PATH, preload variables and cwd Bun configuration. `agent` is
|
|
68
|
+
* the bounded explicit Trash label; it does not invent a harness/session enum. */
|
|
69
|
+
export declare function evaluateNativeSafetyForExecution(event: NativeSafetyEvent, options: {
|
|
70
|
+
home?: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
agent: string;
|
|
73
|
+
}): Promise<NativeSafetyExecutionDecision>;
|
|
74
|
+
/** Classify a synthetic removal through this exact supervisor without executing
|
|
75
|
+
* the returned command or creating a file. A harmless ':' would not test whether
|
|
76
|
+
* the saved PATH can actually resolve Trash. */
|
|
77
|
+
export declare function verifyNativeSafetyCommand(command: string, home?: string): Promise<void>;
|
|
78
|
+
export declare function verifyNativeSafetyRegistration(home?: string): Promise<{
|
|
79
|
+
path: string;
|
|
80
|
+
sha256: string;
|
|
81
|
+
}>;
|
|
82
|
+
/** Read-only compatibility check for native dispatch. This classifies one
|
|
83
|
+
* synthetic removal and requires the execution capability; it never executes
|
|
84
|
+
* the rewrite, creates a fixture, authenticates Trash or proves harness adoption. */
|
|
85
|
+
export declare function verifyNativeSafetyExecutionRegistration(home?: string): Promise<{
|
|
86
|
+
path: string;
|
|
87
|
+
sha256: string;
|
|
88
|
+
}>;
|