@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.
@@ -0,0 +1,158 @@
1
+ import { dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ /**
6
+ * A command called trash may be Apple's unrelated system utility. Resolve
7
+ * package provenance before executing a bounded, credential-free identity
8
+ * probe. Never invoke an unknown executable merely to discover what it is.
9
+ */
10
+ export type TrashBinaryFailure = "missing" | "unsafe_executable" | "unsafe_manifest" | "unsafe_directory" | "unsafe_interpreter" | "unrecognized_package" | "identity_failed" | "unsupported_protocol";
11
+ export type TrashBinaryInspection = { ok: true; path: string; version: string } | { ok: false; code: TrashBinaryFailure };
12
+
13
+ export const BINARY_FAILURES: Record<TrashBinaryFailure, string> = {
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
+
24
+ function trustedOwner(uid: number): boolean {
25
+ return uid === 0 || uid === process.getuid?.();
26
+ }
27
+
28
+ function safeExecutableFile(path: string, systemBinAlias = false): boolean {
29
+ let current = path;
30
+ for (let hop = 0; hop < 32; hop++) {
31
+ if (!isAbsolute(current) || current.length > 4096 || !safeBinaryDirectory("/")) return false;
32
+ const parts = current.split("/"), name = parts.pop();
33
+ if (!name || name === "." || name === "..") return false;
34
+ let parent = "/";
35
+ // Validate literal components before consuming ..; realpath alone hides
36
+ // intermediate writable symlink parents, even when its final target is safe.
37
+ for (const part of parts) {
38
+ if (!part || part === ".") continue;
39
+ if (part === "..") { parent = dirname(parent); continue; }
40
+ const next = join(parent, part), info = lstatSync(next);
41
+ if (systemBinAlias && next === "/bin" && info.isSymbolicLink() && info.uid === 0) {
42
+ const target = realpathSync(next);
43
+ if (!safeBinaryDirectory(target)) return false;
44
+ parent = target; continue;
45
+ }
46
+ if (!info.isDirectory() || !trustedOwner(info.uid) || (info.mode & 0o022)) return false;
47
+ parent = next;
48
+ }
49
+ const candidate = join(parent, name), info = lstatSync(candidate);
50
+ if (!trustedOwner(info.uid)) return false;
51
+ if (info.isSymbolicLink()) {
52
+ const target = readlinkSync(candidate);
53
+ current = isAbsolute(target) ? target : parent + "/" + target;
54
+ continue;
55
+ }
56
+ return info.isFile() && Boolean(info.mode & 0o111) && !(info.mode & 0o022);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function safeInterpreterSearchDirectory(path: string): boolean {
62
+ if (!isAbsolute(path) || resolve(path) !== (path.replace(/\/+$/, "") || "/")) return false;
63
+ // Even an earlier empty PATH directory could acquire a shadow interpreter.
64
+ // Missing directories are safe only beneath verified non-writable ancestry.
65
+ for (let cursor = resolve(path);; cursor = dirname(cursor)) {
66
+ try { lstatSync(cursor); return safeBinaryDirectory(cursor); }
67
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; }
68
+ if (dirname(cursor) === cursor) return false;
69
+ }
70
+ }
71
+
72
+ function safeScriptInterpreter(executable: string, pathValue: string): boolean {
73
+ const fd = openSync(executable, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
74
+ let header: string;
75
+ try {
76
+ if (!fstatSync(fd).isFile()) return false;
77
+ const bytes = Buffer.alloc(256), length = readSync(fd, bytes, 0, bytes.length, 0);
78
+ header = bytes.subarray(0, length).toString("utf8").split("\n")[0]!;
79
+ } finally { closeSync(fd); }
80
+ const direct = /^#!(\/[A-Za-z0-9_./-]+)$/.exec(header);
81
+ if (direct && direct[1] !== "/usr/bin/env") return safeExecutableFile(direct[1]!, true);
82
+ // The published Trash entrypoint uses this exact shebang. Do not execute an
83
+ // env program until the first executable it will actually select is safe.
84
+ // Extra env options/arguments and unresolved interpreters fail closed.
85
+ if (header !== "#!/usr/bin/env bun" || !safeExecutableFile("/usr/bin/env", true)) return false;
86
+ for (const dir of pathValue.split(":").slice(0, 64)) {
87
+ if (!safeInterpreterSearchDirectory(dir)) return false;
88
+ const interpreter = resolve(dir, "bun");
89
+ try {
90
+ if (!statSync(interpreter).isFile()) continue;
91
+ accessSync(interpreter, constants.X_OK);
92
+ } catch { continue; }
93
+ return safeExecutableFile(interpreter, true);
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function safeBinaryDirectory(path: string): boolean {
99
+ let current = path;
100
+ for (;;) {
101
+ const info = lstatSync(current);
102
+ if (!info.isDirectory() || !trustedOwner(info.uid) || (info.mode & 0o022) !== 0) return false;
103
+ const parent = dirname(current);
104
+ if (parent === current) return true;
105
+ current = parent;
106
+ }
107
+ }
108
+
109
+ /** Bounded, credential-free diagnostic shared by pre-action guards and setup checks. */
110
+ export function inspectTrashBinary(env: NodeJS.ProcessEnv = process.env): TrashBinaryInspection {
111
+ const pathValue = env.PATH ?? "";
112
+ const deadline = Date.now() + 2_000;
113
+ let failure: TrashBinaryFailure = "missing";
114
+ const reject = (code: TrashBinaryFailure) => {
115
+ // An unrelated system trash utility must not hide a later permission defect.
116
+ if (failure === "missing" || failure === "unrecognized_package" || code.startsWith("unsafe_")) failure = code;
117
+ };
118
+ for (const dir of pathValue.split(":").slice(0, 64)) {
119
+ if (!isAbsolute(dir) || Date.now() >= deadline) continue;
120
+ const candidate = resolve(dir, "trash");
121
+ try {
122
+ const stat = statSync(candidate);
123
+ if (!stat.isFile() || !trustedOwner(stat.uid) || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) { reject("unsafe_executable"); continue; }
124
+ const executable = realpathSync(candidate);
125
+ // Bin files may be package-manager symlinks. PATH directory ancestry must
126
+ // remain literal and safe: resolving it first would hide writable parents
127
+ // of a directory alias while probe/rewrite still used the original path.
128
+ if (!safeBinaryDirectory(resolve(dir)) || !safeBinaryDirectory(dirname(executable))) { reject("unsafe_directory"); continue; }
129
+ if (!safeExecutableFile(candidate)) { reject("unsafe_directory"); continue; }
130
+ const packageRoot = resolve(dirname(executable), "../..");
131
+ const manifestPath = join(packageRoot, "package.json");
132
+ const manifestStat = statSync(manifestPath);
133
+ if (!manifestStat.isFile() || !trustedOwner(manifestStat.uid) || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0 || lstatSync(manifestPath).isSymbolicLink()) { reject("unsafe_manifest"); continue; }
134
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
135
+ if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
136
+ typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) { reject("unrecognized_package"); continue; }
137
+ try { if (!safeScriptInterpreter(executable, pathValue)) { reject("unsafe_interpreter"); continue; } }
138
+ catch { reject("unsafe_interpreter"); continue; }
139
+ const probe = spawnSync(candidate, ["--identity"], {
140
+ encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
141
+ env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
142
+ });
143
+ if (probe.error || probe.status !== 0) { reject("identity_failed"); continue; }
144
+ const identity = JSON.parse(probe.stdout);
145
+ if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
146
+ identity.guardProtocol === "hasna.trash.guard.v1") return { ok: true, path: candidate, version: manifest.version };
147
+ reject("unsupported_protocol");
148
+ } catch {
149
+ // Missing, unrelated, malformed or unresponsive packages are not targets.
150
+ }
151
+ }
152
+ return { ok: false, code: failure };
153
+ }
154
+
155
+ export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
156
+ const result = inspectTrashBinary(env);
157
+ return result.ok ? result.path : null;
158
+ }
@@ -47,9 +47,9 @@
47
47
  */
48
48
 
49
49
  import { homedir } from "os";
50
- import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
51
- import { readFileSync, realpathSync, statSync } from "fs";
52
- import { spawnSync } from "node:child_process";
50
+ import { isAbsolute, join, normalize, resolve, sep } from "path";
51
+ import { BINARY_FAILURES, findTrashBinary, inspectTrashBinary, type TrashBinaryInspection } from "./binary.js";
52
+ export { findTrashBinary, inspectTrashBinary } from "./binary.js";
53
53
  import {
54
54
  SYSTEM_PROTECTED_ROOTS,
55
55
  getCommand,
@@ -876,42 +876,6 @@ function gitHasDryRun(segment: Segment, verbIndex: number): boolean {
876
876
  /* Rewrite */
877
877
  /* ------------------------------------------------------------------ */
878
878
 
879
- /**
880
- * A command called trash may be Apple's unrelated system utility. Resolve
881
- * package provenance before executing a bounded, credential-free identity
882
- * probe. Never invoke an unknown executable merely to discover what it is.
883
- */
884
- export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
885
- const pathValue = env.PATH ?? "";
886
- const deadline = Date.now() + 2_000;
887
- for (const dir of pathValue.split(":").slice(0, 64)) {
888
- if (!isAbsolute(dir) || Date.now() >= deadline) continue;
889
- const candidate = resolve(dir, "trash");
890
- try {
891
- const stat = statSync(candidate);
892
- if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) continue;
893
- const executable = realpathSync(candidate);
894
- const packageRoot = resolve(dirname(executable), "../..");
895
- const manifestPath = join(packageRoot, "package.json");
896
- const manifestStat = statSync(manifestPath);
897
- if (!manifestStat.isFile() || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0) continue;
898
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
899
- if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
900
- typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) continue;
901
- const probe = spawnSync(candidate, ["--identity"], {
902
- encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
903
- env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
904
- });
905
- if (probe.error || probe.status !== 0) continue;
906
- const identity = JSON.parse(probe.stdout);
907
- if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
908
- identity.guardProtocol === "hasna.trash.guard.v1") return candidate;
909
- } catch {
910
- // Missing, unrelated, malformed or unresponsive packages are not targets.
911
- }
912
- }
913
- return null;
914
- }
915
879
 
916
880
  function shellQuoteWord(word: string): string {
917
881
  if (/^[A-Za-z0-9_\-./+=:,@%^]+$/.test(word)) return word;
@@ -991,6 +955,7 @@ export interface GuardDependencies {
991
955
  home: string;
992
956
  cwd: string;
993
957
  findTrash: (env?: NodeJS.ProcessEnv) => string | null;
958
+ inspectTrash?: (env?: NodeJS.ProcessEnv) => TrashBinaryInspection;
994
959
  }
995
960
 
996
961
  function deny(reason: string): CodewithHookOutput {
@@ -1025,6 +990,11 @@ function allowRewrite(updatedInput: Record<string, unknown>): CodewithHookOutput
1025
990
  */
1026
991
  function resolveTrash(deps: GuardDependencies): { path: string | null; error: string | null } {
1027
992
  try {
993
+ if (deps.inspectTrash) {
994
+ const inspection = deps.inspectTrash();
995
+ return inspection.ok ? { path: inspection.path, error: null }
996
+ : { path: null, error: `${inspection.code}: ${BINARY_FAILURES[inspection.code]}` };
997
+ }
1028
998
  return { path: deps.findTrash(), error: null };
1029
999
  } catch (cause) {
1030
1000
  return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
@@ -1137,7 +1107,7 @@ export async function run(): Promise<void> {
1137
1107
  const command = getCommand(input);
1138
1108
  try {
1139
1109
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
1140
- const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary });
1110
+ const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary });
1141
1111
  // Native Codex rejects `continue` in PreToolUse JSON. Silence is the
1142
1112
  // documented no-op for both Codex and Claude; emit only actual decisions.
1143
1113
  if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
@@ -32,17 +32,39 @@ Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
32
32
  forms like `"$HOME"/.hasna/repos/clones`, which Bash treats identically to the
33
33
  unquoted spelling) are expanded before classification; `apply_patch` tools
34
34
  are inspected through their `Add File` / `Update File` / `Delete File`
35
- markers; Bash relative operands are resolved against the command's cwd when
36
- it sits under the clones root; parenthesized command groups
37
- (`(cd ... && rm -rf ...)`) are unwrapped.
35
+ markers; parenthesized command groups (`(cd ... && rm -rf ...)`) are
36
+ unwrapped.
37
+
38
+ Bash relative operands (a trailing bare name, `.`, `..`, and the operand of
39
+ an output redirection such as `> file` or `>> file`) are resolved against the
40
+ directory the command segment runs in, and only count when that directory is
41
+ under a protected root. That directory starts as the command's cwd. A plain
42
+ `cd DIR` moves it for the segments joined to it by `&&`, so
43
+ `cd /tmp && printf x > out` is not attributed to the session cwd. After `;`,
44
+ a newline or `||` the `cd` may have failed, so the earlier directories stay
45
+ in scope. A `cd` inside `( ... )`, a pipeline or the background does not move
46
+ it, and neither does a `cd` whose target cannot be resolved statically.
47
+ Descriptor duplication (`2>&1`, `>&2`, `1>&-`) is not a write.
38
48
 
39
49
  ## Configuration
40
50
 
41
- Allowed orgs default to `hasna,hasnaxyz,hasna-products` and are overridable
42
- with the `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated). Private
43
- workspace orgs must be added per-install via that var; they are never part
44
- of the public default. The home directory is resolved with `os.homedir()` —
45
- never hardcoded.
51
+ Allowed orgs default to `hasna,hasnaxyz,hasna-products`. Private workspace
52
+ orgs must be added per-install; they are never part of the public default.
53
+ There are two per-install routes, checked in this order:
54
+
55
+ 1. The `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated), for installs
56
+ that run the hook with the harness environment (`hooks run`).
57
+ 2. The file `$HOME/.hasna/hooks/config/workspace-repos-guard-orgs`. The
58
+ native safety registration (`hooks safety install`) runs the guard with
59
+ only `HOME` and `PATH`, so the env var never reaches it and this file is
60
+ the route that does. List the org names separated by commas or whitespace;
61
+ `#` starts a comment. The list replaces the default, so include the
62
+ default orgs you still need. The file and every directory from it up to
63
+ `HOME` must be owned by the user or root and not writable by group or
64
+ others. A missing, untrusted or malformed file is ignored and the default
65
+ applies.
66
+
67
+ The home directory is resolved with `os.homedir()` — never hardcoded.
46
68
 
47
69
  ## Failure mode
48
70
 
@@ -28,22 +28,28 @@
28
28
  *
29
29
  * Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
30
30
  * before classification in Bash targets, file-tool paths, cd operands and
31
- * apply_patch file markers. Bash relative operands (`.`, `..`, bare names)
32
- * are resolved against the command's cwd when it sits under the repos root,
33
- * and against an explicit `cd` into the root when one is present. apply_patch
34
- * tools are inspected through their `*** Add File:` / `*** Update File:` /
35
- * `*** Delete File:` markers. Parenthesized command groups are unwrapped.
31
+ * apply_patch file markers. Bash relative operands (`.`, `..`, bare names,
32
+ * redirection operands) are resolved against the directory the segment runs
33
+ * in: the command's cwd, narrowed by a plain `cd` only across `&&` (see
34
+ * bashTargets). Descriptor duplication (`2>&1`, `>&2`) is not a write.
35
+ * apply_patch tools are inspected through their `*** Add File:` /
36
+ * `*** Update File:` / `*** Delete File:` markers. Parenthesized command
37
+ * groups are unwrapped.
36
38
  *
37
39
  * Allowed orgs default to hasna,hasnaxyz,hasna-products and are overridable
38
- * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated). Private
39
- * workspace orgs must be added per-install via that var; they are never part
40
+ * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated) or, where
41
+ * that var cannot reach the guard (the native safety supervisor passes only
42
+ * HOME and PATH), the per-install file
43
+ * $HOME/.hasna/hooks/config/workspace-repos-guard-orgs. Private workspace
44
+ * orgs must be added per-install through one of those; they are never part
40
45
  * of the public default.
41
46
  * Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
42
47
  * or evaluation error so a guard defect cannot wedge the agent.
43
48
  */
44
49
 
50
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
45
51
  import { homedir } from "os";
46
- import { isAbsolute, join, normalize, relative, resolve, sep } from "path";
52
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
47
53
  import {
48
54
  getCommand,
49
55
  readInput,
@@ -78,15 +84,67 @@ function protectedRoots(home: string): string[] {
78
84
  return [reposRoot(home), legacyRoot(home)];
79
85
  }
80
86
 
81
- export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
87
+ /**
88
+ * Per-install allowed-orgs file, read from the guard's own HOME. The native
89
+ * safety supervisor runs this guard with only HOME and PATH, so the
90
+ * WORKSPACE_REPOS_GUARD_ORGS override never reaches it there; this file is the
91
+ * per-install route that does. Org names are separated by commas or
92
+ * whitespace and `#` starts a comment. The file and every directory from it
93
+ * up to HOME must be owned by the user or root and not writable by group or
94
+ * others (the supervisor's own bundle rule). A missing, untrusted, oversized
95
+ * or malformed file is ignored, which leaves the narrower default list.
96
+ */
97
+ export function allowedOrgsFile(home: string): string {
98
+ return join(home, ".hasna", "hooks", "config", "workspace-repos-guard-orgs");
99
+ }
100
+
101
+ const ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
102
+
103
+ function ownedAndClosed(stat: { uid: number; mode: number }): boolean {
104
+ const uid = process.getuid?.();
105
+ return uid !== undefined && (stat.uid === 0 || stat.uid === uid) && (stat.mode & 0o022) === 0;
106
+ }
107
+
108
+ function readAllowedOrgsFile(home: string): Set<string> | null {
109
+ const file = allowedOrgsFile(home);
110
+ let text: string;
111
+ try {
112
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
113
+ try {
114
+ const stat = fstatSync(fd);
115
+ if (!stat.isFile() || !ownedAndClosed(stat) || stat.size > 4096) return null;
116
+ text = readFileSync(fd, "utf8");
117
+ } finally {
118
+ closeSync(fd);
119
+ }
120
+ const top = normalize(home);
121
+ for (let dir = dirname(file); dir !== top; dir = dirname(dir)) {
122
+ const stat = lstatSync(dir);
123
+ if (!stat.isDirectory() || !ownedAndClosed(stat) || dir === dirname(dir)) return null;
124
+ }
125
+ if (!ownedAndClosed(statSync(top))) return null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ const names = text.replace(/#[^\n]*/g, "").split(/[\s,]+/).filter(Boolean);
130
+ return names.length > 0 && names.every((name) => ORG_NAME.test(name)) ? new Set(names) : null;
131
+ }
132
+
133
+ /**
134
+ * Allowed orgs: the WORKSPACE_REPOS_GUARD_ORGS env var when set, else the
135
+ * per-install file under `home`, else the public default.
136
+ */
137
+ export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): Set<string> {
82
138
  const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
83
- if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
84
- return new Set(
85
- raw
86
- .split(",")
87
- .map((s) => s.trim())
88
- .filter(Boolean)
89
- );
139
+ if (typeof raw === "string" && raw.trim()) {
140
+ return new Set(
141
+ raw
142
+ .split(",")
143
+ .map((s) => s.trim())
144
+ .filter(Boolean)
145
+ );
146
+ }
147
+ return readAllowedOrgsFile(home) ?? new Set(DEFAULT_ORGS);
90
148
  }
91
149
 
92
150
  type Operation = "read" | "write" | "delete";
@@ -139,6 +197,24 @@ export function classifyPath(target: string, root: string, orgs: Set<string>, op
139
197
 
140
198
  const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
141
199
 
200
+ /**
201
+ * Descriptor duplication or close (`2>&1`, `>&2`, `1>&-`, `0<&3`). It only
202
+ * rewires descriptors that are already open and names no file, so it is
203
+ * never a write.
204
+ */
205
+ const FD_DUPLICATION = /\d*[<>]&\s*(?:\d+|-)(?=$|[\s;&|)])/g;
206
+
207
+ /**
208
+ * An output redirection and its file operand: `>`, `>>`, `>|`, `&>`, `&>>`,
209
+ * `<>` and `>&word`, optionally fd-prefixed (`2>`, `2>>`). Apply it only after
210
+ * FD_DUPLICATION is removed, so a remaining `>&word` names a file.
211
+ */
212
+ const OUTPUT_REDIRECT = /(?:\d+|&)?(?:>>|>\||>&|<>|>)\s*([^\s;&|<>()\x60]*)/g;
213
+
214
+ function withoutFdDuplication(segment: string): string {
215
+ return segment.replace(FD_DUPLICATION, " ");
216
+ }
217
+
142
218
  /**
143
219
  * Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
144
220
  * unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
@@ -146,7 +222,8 @@ const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
146
222
  * in-place flag is a stream filter (redirection is caught separately);
147
223
  * rsync/scp/truncate and inline interpreters (python3 -c, node -e, bun -e)
148
224
  * are treated as writes — conservative: they only matter when a path under
149
- * the protected root is also present.
225
+ * the protected root is also present. An output redirection is a write;
226
+ * descriptor duplication (`2>&1`) and `&` are not.
150
227
  */
151
228
  function segmentOperation(segment: string): Operation {
152
229
  const trimmed = segment.trim();
@@ -171,7 +248,7 @@ function segmentOperation(segment: string): Operation {
171
248
  } else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
172
249
  return "write";
173
250
  }
174
- if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed)) return "write";
251
+ if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || />/.test(withoutFdDuplication(trimmed))) return "write";
175
252
  return "read";
176
253
  }
177
254
 
@@ -224,34 +301,79 @@ function regexEscape(text: string): string {
224
301
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
225
302
  }
226
303
 
304
+ /**
305
+ * Directories a `cd` operand leads to from each candidate cwd, or null when
306
+ * the operand cannot be resolved statically (`cd -`, variables, globs,
307
+ * command substitution). A bare `cd` goes home.
308
+ */
309
+ function cdDestinations(operand: string | undefined, bases: string[], home: string): string[] | null {
310
+ if (operand === undefined) return [home];
311
+ let word = expandHomeSpelling(operand, home);
312
+ const quoted = word.match(/^(['"])(.*)\1$/);
313
+ if (quoted) word = quoted[2];
314
+ if (!word || word === "-" || /[$\x60*?[\]{}~\\'"]/.test(word)) return null;
315
+ return [...new Set(bases.map((base) => resolve(base, word)))];
316
+ }
317
+
318
+ const REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
319
+
227
320
  /**
228
321
  * Extract protected-repo-checkout targets from a Bash command. Recognises
229
322
  * explicit `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references
230
323
  * (expanded before classification) under EITHER protected root (the canonical
231
- * clones root or the legacy workspace/repos root), plus a trailing relative
232
- * operand (`.`, `..`, a bare name) when the command `cd`s into one of the
233
- * protected roots or the command's cwd already sits under one. Tokens outside
234
- * both protected roots are ignored; reads are returned so callers can decide
235
- * (they are never blocked).
324
+ * clones root or the legacy workspace/repos root). When a writing or deleting
325
+ * segment names no protected path, its relative operands are resolved against
326
+ * every directory the segment can run in, and count only when that directory
327
+ * sits under a protected root: the redirection operands (`> file`), plus the
328
+ * trailing operand (`.`, `..`, a bare name) when the command itself writes or
329
+ * deletes. Tokens outside both protected roots are ignored; reads are
330
+ * returned so callers can decide (they are never blocked).
331
+ *
332
+ * The directory a segment runs in starts as the command's cwd. A plain
333
+ * `cd DIR` narrows it for the segments after it only while they are joined
334
+ * by `&&` from the start of their and-or list, because only then do they run
335
+ * after a successful cd. After `;`, a newline or `||` the cd may have failed,
336
+ * so every directory reached so far stays in scope; a cd inside `( ... )`, a
337
+ * pipeline or the background does not change the outer cwd, and a cd that
338
+ * cannot be resolved statically never narrows it.
236
339
  */
237
340
  export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
238
341
  if (!command) return [];
239
342
  const roots = protectedRoots(home);
240
- const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
343
+ const underRoot = (path: string) => roots.some((root) => path === root || path.startsWith(`${root}${sep}`));
344
+ const parts = command.split(/(\s*&&\s*|\s*\|\|\s*|;\s*|\n+)/);
241
345
 
242
346
  const targets: PathTarget[] = [];
243
- let cwdUnderRepos: string | null = null;
347
+ // Every directory the current segment can run in, every directory any
348
+ // segment so far can have left the shell in, and whether the current and-or
349
+ // list has only been joined by `&&` so far.
350
+ let current = [cwd];
351
+ let reachable = new Set([cwd]);
352
+ let certain = true;
353
+ const subshells: Array<{ current: string[]; reachable: Set<string>; certain: boolean }> = [];
354
+
355
+ for (let index = 0; index < parts.length; index += 2) {
356
+ const separator = index === 0 ? "" : parts[index - 1].trim();
357
+ if (separator === "||") certain = false;
358
+ else if (separator !== "&&") certain = true;
359
+ if (separator !== "&&" || !certain) current = [...reachable];
360
+
361
+ const rawSegment = parts[index];
362
+ const opens = (rawSegment.match(/^[\s({]*/)?.[0].match(/\(/g) ?? []).length;
363
+ const closes = (rawSegment.match(/[\s)}]*$/)?.[0].match(/\)/g) ?? []).length;
364
+ for (let i = 0; i < opens; i++) subshells.push({ current: [...current], reachable: new Set(reachable), certain });
244
365
 
245
- for (const rawSegment of segments) {
246
366
  const segment = unwrapSegment(rawSegment);
247
367
  const op = segmentOperation(segment);
248
368
 
249
- const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
250
- if (cdMatch) {
251
- const cdTarget = expandHomeSpelling(cdMatch[1], home);
252
- if (cdTarget && roots.some((root) => cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) {
253
- cwdUnderRepos = cdTarget;
254
- }
369
+ const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
370
+ const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
371
+ if (embeddedCd) {
372
+ // A cd sharing its segment (pipeline, background, compound command) may
373
+ // or may not have moved the shell: widen, never narrow.
374
+ for (const dir of cdDestinations(embeddedCd[1], current, home) ?? []) reachable.add(dir);
375
+ certain = false;
376
+ current = [...reachable];
255
377
  }
256
378
 
257
379
  const homeLiteral = regexEscape(home);
@@ -267,16 +389,32 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
267
389
  }
268
390
 
269
391
  if (!foundExplicit && (op === "delete" || op === "write")) {
270
- const base =
271
- cwdUnderRepos ??
272
- (roots.some((root) => cwd === root || cwd.startsWith(`${root}${sep}`)) ? cwd : null);
273
- if (base) {
274
- const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
275
- if (relMatch) {
276
- targets.push({ path: normalize(resolve(base, relMatch[1])), op });
392
+ const operands: string[] = [];
393
+ const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand: string) => {
394
+ if (operand) operands.push(operand);
395
+ return " ";
396
+ });
397
+ const commandOp = segmentOperation(commandPart);
398
+ const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
399
+ for (const base of current.filter(underRoot)) {
400
+ for (const operand of operands) {
401
+ targets.push({ path: normalize(resolve(base, expandHomeSpelling(operand, home))), op: "write" });
277
402
  }
403
+ if (relMatch) targets.push({ path: normalize(resolve(base, relMatch[1])), op: commandOp });
278
404
  }
279
405
  }
406
+
407
+ if (plainCd) {
408
+ const next = cdDestinations(plainCd[1], current, home);
409
+ if (next) {
410
+ for (const dir of next) reachable.add(dir);
411
+ if (certain) current = next;
412
+ } else {
413
+ certain = false;
414
+ }
415
+ }
416
+
417
+ for (let i = 0; i < closes && subshells.length > 0; i++) ({ current, reachable, certain } = subshells.pop()!);
280
418
  }
281
419
 
282
420
  return targets;
@@ -299,14 +437,15 @@ export function patchTargets(patch: string, cwd: string): PathTarget[] {
299
437
  return targets;
300
438
  }
301
439
 
302
- export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput; warnings: string[] } {
440
+ /** `options.home` defaults to os.homedir(); tests pass a private home. */
441
+ export function evaluate(input: CodewithHookInput, options: { home?: string } = {}): { output: CodewithHookOutput; warnings: string[] } {
303
442
  const warnings: string[] = [];
304
443
  if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
305
444
 
306
445
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
307
- const home = homedir();
446
+ const home = options.home ?? homedir();
308
447
  const roots = protectedRoots(home);
309
- const orgs = resolveAllowedOrgs();
448
+ const orgs = resolveAllowedOrgs(process.env, home);
310
449
 
311
450
  if (tool === "Bash") {
312
451
  const command = getCommand(input);
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute } from "node:path";
3
- import { evaluate as evaluateTrash, findTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
3
+ import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
4
  import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
5
  import type { CodewithHookInput } from "./codewith-native-common";
6
6
 
@@ -24,5 +24,5 @@ const handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.comma
24
24
  const verdict = repos.decision === "block"
25
25
  ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } }
26
26
  : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } }
27
- : name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary }) : { continue: true };
27
+ : name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
28
28
  process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,10 @@
20
20
  "./storage": {
21
21
  "types": "./dist/storage.d.ts",
22
22
  "import": "./dist/storage.js"
23
+ },
24
+ "./native-safety": {
25
+ "types": "./dist/native-safety.d.ts",
26
+ "import": "./dist/native-safety.js"
23
27
  }
24
28
  },
25
29
  "main": "./dist/index.js",
@@ -37,7 +41,7 @@
37
41
  "postinstall": "node scripts/ensure-profiles-dir.mjs",
38
42
  "scan:artifact": "bun scripts/artifact-scan.ts",
39
43
  "prepack": "bun run build && bun run scan:artifact",
40
- "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --define import.meta.main=false"
44
+ "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --define import.meta.main=false && bun build ./src/native-safety.ts --outdir ./dist --target bun"
41
45
  },
42
46
  "keywords": [
43
47
  "claude-code",