@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.
@@ -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,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 = `${shellQuoteWord(trashPath)} guard`;
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
- const rewritten = rewriteCommand(command, scanned.rmHits, trashPath);
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 isAbsolute2, join as join2, normalize as normalize2, relative, resolve as resolve2, sep as sep2 } from "path";
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 join2(home, ".hasna", "repos", "clones");
1095
+ return join3(home, ".hasna", "repos", "clones");
911
1096
  }
912
1097
  function legacyRoot(home) {
913
- return join2(home, "workspace", "repos");
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 join2(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
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 = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
1115
+ const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
931
1116
  try {
932
- const stat = fstatSync(fd);
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
- closeSync(fd);
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 = lstatSync(dir);
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 === ".." || isAbsolute2(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 join2(home, body.slice(2));
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 join2(home, body.slice("$HOME/".length));
1228
+ return join3(home, body.slice("$HOME/".length));
1044
1229
  if (body.startsWith("${HOME}/"))
1045
- return join2(home, body.slice("${HOME}/".length));
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) => resolve2(base, word)))];
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(resolve2(base, expandHomeSpelling2(operand, home))), op: "write" });
1315
+ targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
1131
1316
  }
1132
1317
  if (relMatch)
1133
- targets.push({ path: normalize2(resolve2(base, relMatch[1])), op: commandOp });
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(resolve2(cwd, m[2])), op });
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 = isAbsolute2(expanded) ? normalize2(expanded) : resolve2(cwd, expanded);
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" || !isAbsolute3(input.cwd))
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.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,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
+ }>;