@hasna/hooks 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,26 +1052,63 @@ function evaluate(input, deps) {
899
1052
  if (false) {}
900
1053
 
901
1054
  // hooks/hook-workspace-repos-guard/src/hook.ts
1055
+ import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
902
1056
  import { homedir } from "os";
903
- import { isAbsolute as 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";
904
1058
  var FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
905
1059
  var PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
906
1060
  var DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
907
1061
  var RULE = "workspace-repos-guard";
908
1062
  function reposRoot(home) {
909
- return join2(home, ".hasna", "repos", "clones");
1063
+ return join3(home, ".hasna", "repos", "clones");
910
1064
  }
911
1065
  function legacyRoot(home) {
912
- return join2(home, "workspace", "repos");
1066
+ return join3(home, "workspace", "repos");
913
1067
  }
914
1068
  function protectedRoots(home) {
915
1069
  return [reposRoot(home), legacyRoot(home)];
916
1070
  }
917
- function resolveAllowedOrgs(env = process.env) {
1071
+ function allowedOrgsFile(home) {
1072
+ return join3(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
1073
+ }
1074
+ var ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
1075
+ function ownedAndClosed(stat) {
1076
+ const uid = process.getuid?.();
1077
+ return uid !== undefined && (stat.uid === 0 || stat.uid === uid) && (stat.mode & 18) === 0;
1078
+ }
1079
+ function readAllowedOrgsFile(home) {
1080
+ const file = allowedOrgsFile(home);
1081
+ let text;
1082
+ try {
1083
+ const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
1084
+ try {
1085
+ const stat = fstatSync2(fd);
1086
+ if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096)
1087
+ return null;
1088
+ text = readFileSync2(fd, "utf8");
1089
+ } finally {
1090
+ closeSync2(fd);
1091
+ }
1092
+ const top = normalize2(home);
1093
+ for (let dir = dirname2(file);dir !== top; dir = dirname2(dir)) {
1094
+ const stat = lstatSync2(dir);
1095
+ if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname2(dir))
1096
+ return null;
1097
+ }
1098
+ if (!ownedAndClosed(statSync2(top)))
1099
+ return null;
1100
+ } catch {
1101
+ return null;
1102
+ }
1103
+ const names = text.replace(/#[^\n]*/g, "").split(/[\s,]+/).filter(Boolean);
1104
+ return names.length > 0 && names.every((name) => ORG_NAME.test(name)) ? new Set(names) : null;
1105
+ }
1106
+ function resolveAllowedOrgs(env = process.env, home = homedir()) {
918
1107
  const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
919
- if (typeof raw !== "string" || !raw.trim())
920
- return new Set(DEFAULT_ORGS);
921
- return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
1108
+ if (typeof raw === "string" && raw.trim()) {
1109
+ return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
1110
+ }
1111
+ return readAllowedOrgsFile(home) ?? new Set(DEFAULT_ORGS);
922
1112
  }
923
1113
  function classifyPath(target, root, orgs, op) {
924
1114
  if (op === "read")
@@ -929,7 +1119,7 @@ function classifyPath(target, root, orgs, op) {
929
1119
  return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
930
1120
  return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
931
1121
  }
932
- if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute2(rel)) {
1122
+ if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute3(rel)) {
933
1123
  return { blocked: false };
934
1124
  }
935
1125
  const segments = rel.split(sep2).filter(Boolean);
@@ -952,6 +1142,11 @@ function classifyPath(target, root, orgs, op) {
952
1142
  return { blocked: false };
953
1143
  }
954
1144
  var WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
1145
+ var FD_DUPLICATION = /\d*[<>]&\s*(?:\d+|-)(?=$|[\s;&|)])/g;
1146
+ var OUTPUT_REDIRECT = /(?:\d+|&)?(?:>>|>\||>&|<>|>)\s*([^\s;&|<>()\x60]*)/g;
1147
+ function withoutFdDuplication(segment) {
1148
+ return segment.replace(FD_DUPLICATION, " ");
1149
+ }
955
1150
  function segmentOperation(segment) {
956
1151
  const trimmed = segment.trim();
957
1152
  if (!trimmed)
@@ -980,7 +1175,7 @@ function segmentOperation(segment) {
980
1175
  } else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
981
1176
  return "write";
982
1177
  }
983
- if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed))
1178
+ if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || />/.test(withoutFdDuplication(trimmed)))
984
1179
  return "write";
985
1180
  return "read";
986
1181
  }
@@ -994,13 +1189,13 @@ function expandHomeSpelling2(token, home) {
994
1189
  if (body === "~")
995
1190
  return home;
996
1191
  if (body.startsWith("~/"))
997
- return join2(home, body.slice(2));
1192
+ return join3(home, body.slice(2));
998
1193
  if (body === "$HOME" || body === "${HOME}")
999
1194
  return home;
1000
1195
  if (body.startsWith("$HOME/"))
1001
- return join2(home, body.slice("$HOME/".length));
1196
+ return join3(home, body.slice("$HOME/".length));
1002
1197
  if (body.startsWith("${HOME}/"))
1003
- return join2(home, body.slice("${HOME}/".length));
1198
+ return join3(home, body.slice("${HOME}/".length));
1004
1199
  const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
1005
1200
  if (homeMatch)
1006
1201
  return home + "/" + body.slice(homeMatch[0].length);
@@ -1017,22 +1212,51 @@ function unwrapSegment(segment) {
1017
1212
  function regexEscape(text) {
1018
1213
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1019
1214
  }
1215
+ function cdDestinations(operand, bases, home) {
1216
+ if (operand === undefined)
1217
+ return [home];
1218
+ let word = expandHomeSpelling2(operand, home);
1219
+ const quoted = word.match(/^(['"])(.*)\1$/);
1220
+ if (quoted)
1221
+ word = quoted[2];
1222
+ if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word))
1223
+ return null;
1224
+ return [...new Set(bases.map((base) => resolve3(base, word)))];
1225
+ }
1226
+ var REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
1020
1227
  function bashTargets(command, home, cwd) {
1021
1228
  if (!command)
1022
1229
  return [];
1023
1230
  const roots = protectedRoots(home);
1024
- const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
1231
+ const underRoot = (path) => roots.some((root) => path === root || path.startsWith(`${root}${sep2}`));
1232
+ const parts = command.split(/(\s*&&\s*|\s*\|\|\s*|;\s*|\n+)/);
1025
1233
  const targets = [];
1026
- let cwdUnderRepos = null;
1027
- for (const rawSegment of segments) {
1234
+ let current = [cwd];
1235
+ let reachable = new Set([cwd]);
1236
+ let certain = true;
1237
+ const subshells = [];
1238
+ for (let index = 0;index < parts.length; index += 2) {
1239
+ const separator = index === 0 ? "" : parts[index - 1].trim();
1240
+ if (separator === "||")
1241
+ certain = false;
1242
+ else if (separator !== "&&")
1243
+ certain = true;
1244
+ if (separator !== "&&" || !certain)
1245
+ current = [...reachable];
1246
+ const rawSegment = parts[index];
1247
+ const opens = (rawSegment.match(/^[\s({]*/)?.[0].match(/\(/g) ?? []).length;
1248
+ const closes = (rawSegment.match(/[\s)}]*$/)?.[0].match(/\)/g) ?? []).length;
1249
+ for (let i = 0;i < opens; i++)
1250
+ subshells.push({ current: [...current], reachable: new Set(reachable), certain });
1028
1251
  const segment = unwrapSegment(rawSegment);
1029
1252
  const op = segmentOperation(segment);
1030
- const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
1031
- if (cdMatch) {
1032
- const cdTarget = expandHomeSpelling2(cdMatch[1], home);
1033
- if (cdTarget && roots.some((root) => cdTarget === root || cdTarget.startsWith(`${root}${sep2}`))) {
1034
- cwdUnderRepos = cdTarget;
1035
- }
1253
+ const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
1254
+ const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
1255
+ if (embeddedCd) {
1256
+ for (const dir of cdDestinations(embeddedCd[1], current, home) ?? [])
1257
+ reachable.add(dir);
1258
+ certain = false;
1259
+ current = [...reachable];
1036
1260
  }
1037
1261
  const homeLiteral = regexEscape(home);
1038
1262
  const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
@@ -1046,14 +1270,35 @@ function bashTargets(command, home, cwd) {
1046
1270
  targets.push({ path: normalize2(expanded).replace(/\/+$/, ""), op });
1047
1271
  }
1048
1272
  if (!foundExplicit && (op === "delete" || op === "write")) {
1049
- const base = cwdUnderRepos ?? (roots.some((root) => cwd === root || cwd.startsWith(`${root}${sep2}`)) ? cwd : null);
1050
- if (base) {
1051
- const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
1052
- if (relMatch) {
1053
- targets.push({ path: normalize2(resolve2(base, relMatch[1])), op });
1273
+ const operands = [];
1274
+ const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand) => {
1275
+ if (operand)
1276
+ operands.push(operand);
1277
+ return " ";
1278
+ });
1279
+ const commandOp = segmentOperation(commandPart);
1280
+ const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
1281
+ for (const base of current.filter(underRoot)) {
1282
+ for (const operand of operands) {
1283
+ targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
1054
1284
  }
1285
+ if (relMatch)
1286
+ targets.push({ path: normalize2(resolve3(base, relMatch[1])), op: commandOp });
1287
+ }
1288
+ }
1289
+ if (plainCd) {
1290
+ const next = cdDestinations(plainCd[1], current, home);
1291
+ if (next) {
1292
+ for (const dir of next)
1293
+ reachable.add(dir);
1294
+ if (certain)
1295
+ current = next;
1296
+ } else {
1297
+ certain = false;
1055
1298
  }
1056
1299
  }
1300
+ for (let i = 0;i < closes && subshells.length > 0; i++)
1301
+ ({ current, reachable, certain } = subshells.pop());
1057
1302
  }
1058
1303
  return targets;
1059
1304
  }
@@ -1063,18 +1308,18 @@ function patchTargets(patch, cwd) {
1063
1308
  let m;
1064
1309
  while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
1065
1310
  const op = m[1] === "Delete File" ? "delete" : "write";
1066
- targets.push({ path: normalize2(resolve2(cwd, m[2])), op });
1311
+ targets.push({ path: normalize2(resolve3(cwd, m[2])), op });
1067
1312
  }
1068
1313
  return targets;
1069
1314
  }
1070
- function evaluate2(input) {
1315
+ function evaluate2(input, options = {}) {
1071
1316
  const warnings = [];
1072
1317
  if (input.hook_event_name !== "PreToolUse")
1073
1318
  return { output: { continue: true }, warnings };
1074
1319
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
1075
- const home = homedir();
1320
+ const home = options.home ?? homedir();
1076
1321
  const roots = protectedRoots(home);
1077
- const orgs = resolveAllowedOrgs();
1322
+ const orgs = resolveAllowedOrgs(process.env, home);
1078
1323
  if (tool === "Bash") {
1079
1324
  const command = getCommand(input);
1080
1325
  if (!command)
@@ -1114,7 +1359,7 @@ function evaluate2(input) {
1114
1359
  if (!raw)
1115
1360
  return { output: { continue: true }, warnings };
1116
1361
  const expanded = expandHomeSpelling2(raw, home);
1117
- const target = isAbsolute2(expanded) ? normalize2(expanded) : resolve2(cwd, expanded);
1362
+ const target = isAbsolute3(expanded) ? normalize2(expanded) : resolve3(cwd, expanded);
1118
1363
  for (const root of roots) {
1119
1364
  const verdict = classifyPath(target, root, orgs, "write");
1120
1365
  if (verdict.blocked) {
@@ -1130,7 +1375,7 @@ var name = process.argv[1];
1130
1375
  if (name !== "trash-guard" && name !== "workspace-repos-guard")
1131
1376
  throw new Error("Invalid safety capability");
1132
1377
  var input = JSON.parse(await Bun.stdin.text());
1133
- if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string" || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input) || typeof input.cwd !== "string" || !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))
1134
1379
  throw new Error("Invalid safety input");
1135
1380
  if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
1136
1381
  throw new Error("Invalid shell input");
@@ -1144,5 +1389,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
1144
1389
  }
1145
1390
  var repos = evaluate2(input).output;
1146
1391
  var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
1147
- var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary }) : { continue: true };
1392
+ var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
1148
1393
  process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
package/bin/serve.js CHANGED
@@ -676,7 +676,7 @@ function getHook(name) {
676
676
  import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
677
677
  import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
678
678
 
679
- // ../hooks0102-standalone-lock/node_modules/zod/v3/external.js
679
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
680
680
  var exports_external = {};
681
681
  __export(exports_external, {
682
682
  void: () => voidType,
@@ -788,7 +788,7 @@ __export(exports_external, {
788
788
  BRAND: () => BRAND
789
789
  });
790
790
 
791
- // ../hooks0102-standalone-lock/node_modules/zod/v3/helpers/util.js
791
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
792
792
  var util;
793
793
  (function(util2) {
794
794
  util2.assertEqual = (_) => {};
@@ -919,7 +919,7 @@ var getParsedType = (data) => {
919
919
  }
920
920
  };
921
921
 
922
- // ../hooks0102-standalone-lock/node_modules/zod/v3/ZodError.js
922
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/ZodError.js
923
923
  var ZodIssueCode = util.arrayToEnum([
924
924
  "invalid_type",
925
925
  "invalid_literal",
@@ -1038,7 +1038,7 @@ ZodError.create = (issues) => {
1038
1038
  return error;
1039
1039
  };
1040
1040
 
1041
- // ../hooks0102-standalone-lock/node_modules/zod/v3/locales/en.js
1041
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/locales/en.js
1042
1042
  var errorMap = (issue, _ctx) => {
1043
1043
  let message;
1044
1044
  switch (issue.code) {
@@ -1141,7 +1141,7 @@ var errorMap = (issue, _ctx) => {
1141
1141
  };
1142
1142
  var en_default = errorMap;
1143
1143
 
1144
- // ../hooks0102-standalone-lock/node_modules/zod/v3/errors.js
1144
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/errors.js
1145
1145
  var overrideErrorMap = en_default;
1146
1146
  function setErrorMap(map) {
1147
1147
  overrideErrorMap = map;
@@ -1149,7 +1149,7 @@ function setErrorMap(map) {
1149
1149
  function getErrorMap() {
1150
1150
  return overrideErrorMap;
1151
1151
  }
1152
- // ../hooks0102-standalone-lock/node_modules/zod/v3/helpers/parseUtil.js
1152
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
1153
1153
  var makeIssue = (params) => {
1154
1154
  const { data, path, errorMaps, issueData } = params;
1155
1155
  const fullPath = [...path, ...issueData.path || []];
@@ -1255,14 +1255,14 @@ var isAborted = (x) => x.status === "aborted";
1255
1255
  var isDirty = (x) => x.status === "dirty";
1256
1256
  var isValid = (x) => x.status === "valid";
1257
1257
  var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise;
1258
- // ../hooks0102-standalone-lock/node_modules/zod/v3/helpers/errorUtil.js
1258
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js
1259
1259
  var errorUtil;
1260
1260
  (function(errorUtil2) {
1261
1261
  errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {};
1262
1262
  errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message;
1263
1263
  })(errorUtil || (errorUtil = {}));
1264
1264
 
1265
- // ../hooks0102-standalone-lock/node_modules/zod/v3/types.js
1265
+ // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/types.js
1266
1266
  class ParseInputLazyPath {
1267
1267
  constructor(parent, value, path, key) {
1268
1268
  this._cachedPath = [];
@@ -5362,7 +5362,7 @@ import { readFileSync as readFileSync5 } from "fs";
5362
5362
  import { dirname as dirname4, join as join8 } from "path";
5363
5363
  import { fileURLToPath as fileURLToPath5 } from "url";
5364
5364
 
5365
- // ../hooks0102-standalone-lock/node_modules/@hasna/contracts/dist/client/transport.js
5365
+ // ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/node_modules/@hasna/contracts/dist/client/transport.js
5366
5366
  import { spawnSync } from "child_process";
5367
5367
  import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync22 } from "fs";
5368
5368
  import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";