@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.
@@ -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 isAbsolute3 } from "path";
4
+ import { isAbsolute as isAbsolute4 } from "path";
5
5
 
6
6
  // hooks/hook-trash-guard/src/hook.ts
7
- import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
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) => join(home, ...suffix.split("/")));
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 join(home, token.slice(2));
689
+ return join2(home, token.slice(2));
505
690
  if (token.startsWith("$HOME/"))
506
- return join(home, token.slice("$HOME/".length));
691
+ return join2(home, token.slice("$HOME/".length));
507
692
  if (token.startsWith("${HOME}/"))
508
- return join(home, token.slice("${HOME}/".length));
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 = isAbsolute(decoded) ? normalize(decoded) : resolve(cwd, decoded);
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 = `${join(home, ".hasna", "scratchpad", "scratch")}${sep}`;
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 = join(home, suffix);
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 isAbsolute2, join as join2, normalize as normalize2, relative, resolve as resolve2, sep as sep2 } from "path";
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 join2(home, ".hasna", "repos", "clones");
1063
+ return join3(home, ".hasna", "repos", "clones");
911
1064
  }
912
1065
  function legacyRoot(home) {
913
- return join2(home, "workspace", "repos");
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 join2(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
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 = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
1083
+ const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
931
1084
  try {
932
- const stat = fstatSync(fd);
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
- closeSync(fd);
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 = lstatSync(dir);
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 === ".." || isAbsolute2(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 join2(home, body.slice(2));
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 join2(home, body.slice("$HOME/".length));
1196
+ return join3(home, body.slice("$HOME/".length));
1044
1197
  if (body.startsWith("${HOME}/"))
1045
- return join2(home, body.slice("${HOME}/".length));
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) => resolve2(base, word)))];
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(resolve2(base, expandHomeSpelling2(operand, home))), op: "write" });
1283
+ targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
1131
1284
  }
1132
1285
  if (relMatch)
1133
- targets.push({ path: normalize2(resolve2(base, relMatch[1])), op: commandOp });
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(resolve2(cwd, m[2])), op });
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 = isAbsolute2(expanded) ? normalize2(expanded) : resolve2(cwd, expanded);
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" || !isAbsolute3(input.cwd))
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.name : undefined;
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
+ }>;