@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.
- package/README.md +41 -0
- package/bin/hooks-mcp.js +6 -3
- package/bin/index.js +1232 -319
- package/bin/native-safety-entry.js +325 -80
- package/bin/serve.js +9 -9
- package/dist/index.js +16 -13
- package/dist/lib/native-safety.d.ts +1 -0
- package/dist/native-safety.d.ts +58 -0
- package/dist/native-safety.js +363 -0
- package/dist/sdk/index.js +1 -1
- package/hooks/hook-trash-guard/src/binary.ts +158 -0
- package/hooks/hook-trash-guard/src/hook.ts +10 -40
- package/hooks/hook-workspace-repos-guard/README.md +30 -8
- package/hooks/hook-workspace-repos-guard/src/hook.ts +181 -42
- package/hooks/native-safety-entry.ts +2 -2
- package/package.json +6 -2
- package/scripts/validate-package.ts +12 -3
|
@@ -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 {
|
|
51
|
-
import {
|
|
52
|
-
|
|
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;
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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)
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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) ||
|
|
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)
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* (
|
|
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
|
|
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
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|