@hasna/hooks 0.10.3 → 0.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/bin/hooks-mcp.js +4 -1
- package/bin/index.js +589 -111
- package/bin/native-safety-entry.js +266 -70
- package/dist/index.js +4 -1
- package/dist/lib/native-safety.d.ts +1 -0
- package/dist/native-safety.d.ts +88 -0
- package/dist/native-safety.js +401 -0
- package/hooks/hook-trash-guard/src/binary.ts +158 -0
- package/hooks/hook-trash-guard/src/hook.ts +38 -43
- package/hooks/native-safety-entry.ts +10 -2
- package/package.json +6 -2
- package/scripts/validate-package.ts +12 -3
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/native-safety.ts
|
|
3
|
+
import { execFile } from "child_process";
|
|
4
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
5
|
+
import { closeSync as closeSync2, constants as constants2, existsSync as existsSync2, fstatSync as fstatSync2, fsyncSync, linkSync, lstatSync as lstatSync2, mkdirSync, openSync as openSync2, readFileSync as readFileSync2, realpathSync as realpathSync2, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve } from "path";
|
|
8
|
+
|
|
9
|
+
// src/lib/native-safety.ts
|
|
10
|
+
import { createHash } from "crypto";
|
|
11
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "fs";
|
|
12
|
+
import { dirname, isAbsolute, join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
function isNativeSafetyName(name) {
|
|
15
|
+
return name === "trash-guard" || name === "workspace-repos-guard";
|
|
16
|
+
}
|
|
17
|
+
var supervisorSource = String.raw`async function supervise(encoded) {
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
const crypto = require("node:crypto");
|
|
21
|
+
const cp = require("node:child_process");
|
|
22
|
+
const fail = () => { process.stderr.write("Native safety guard refused: verification or execution failed.\n"); process.exit(2); };
|
|
23
|
+
try {
|
|
24
|
+
const b = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
25
|
+
const check = (file, hash, maximum) => {
|
|
26
|
+
if (!path.isAbsolute(file) || fs.realpathSync(file) !== file) throw Error();
|
|
27
|
+
for (let parent = path.dirname(file);;) {
|
|
28
|
+
const s = fs.lstatSync(parent);
|
|
29
|
+
if (!s.isDirectory() || ![0, process.getuid()].includes(s.uid) || (s.mode & 0o022)) throw Error();
|
|
30
|
+
if (parent === path.dirname(parent)) break;
|
|
31
|
+
parent = path.dirname(parent);
|
|
32
|
+
}
|
|
33
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
34
|
+
try {
|
|
35
|
+
const s = fs.fstatSync(fd);
|
|
36
|
+
if (!s.isFile() || ![0, process.getuid()].includes(s.uid) || (s.mode & 0o022) || s.size > maximum) throw Error();
|
|
37
|
+
const bytes = fs.readFileSync(fd);
|
|
38
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== hash) throw Error();
|
|
39
|
+
return bytes;
|
|
40
|
+
} finally { fs.closeSync(fd); }
|
|
41
|
+
};
|
|
42
|
+
check(b.runtime, b.runtimeSHA256, 200_000_000);
|
|
43
|
+
const bytes = check(b.worker, b.workerSHA256, 256_000);
|
|
44
|
+
const deadline = setTimeout(fail, 1500);
|
|
45
|
+
const chunks = []; let size = 0;
|
|
46
|
+
for await (const chunk of process.stdin) {
|
|
47
|
+
size += chunk.length;
|
|
48
|
+
if (size > 1_048_576) throw Error();
|
|
49
|
+
chunks.push(chunk);
|
|
50
|
+
}
|
|
51
|
+
clearTimeout(deadline);
|
|
52
|
+
const input = Buffer.concat(chunks);
|
|
53
|
+
const child = cp.spawnSync(b.runtime, ["--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", "--eval", bytes.toString("utf8"), b.name], {
|
|
54
|
+
input, env: { HOME: b.home, PATH: b.path }, cwd: "/", timeout: 2500, killSignal: "SIGKILL", maxBuffer: 1_048_576,
|
|
55
|
+
});
|
|
56
|
+
if (child.error || child.status !== 0 || child.signal || child.stderr.length) throw Error();
|
|
57
|
+
const result = JSON.parse(child.stdout.toString("utf8"));
|
|
58
|
+
if (!result || Array.isArray(result) || Object.keys(result).length !== 1) throw Error();
|
|
59
|
+
if (result.verdict === null) return;
|
|
60
|
+
const output = result.verdict?.hookSpecificOutput;
|
|
61
|
+
if (!output || Object.keys(result.verdict).length !== 1 || output.hookEventName !== "PreToolUse") throw Error();
|
|
62
|
+
if (output.permissionDecision === "deny") {
|
|
63
|
+
if (Object.keys(output).sort().join() !== "hookEventName,permissionDecision,permissionDecisionReason" || typeof output.permissionDecisionReason !== "string" || !output.permissionDecisionReason.trim()) throw Error();
|
|
64
|
+
} else if (output.permissionDecision === "allow") {
|
|
65
|
+
if (b.name !== "trash-guard" || Object.keys(output).sort().join() !== "hookEventName,permissionDecision,updatedInput" || !output.updatedInput || typeof output.updatedInput.command !== "string" || !output.updatedInput.command.trim()) throw Error();
|
|
66
|
+
} else throw Error();
|
|
67
|
+
process.stdout.write(JSON.stringify(result.verdict));
|
|
68
|
+
} catch { fail(); }
|
|
69
|
+
}`;
|
|
70
|
+
var quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
71
|
+
var program = `(${supervisorSource})(process.argv[1]);`;
|
|
72
|
+
var shell = `cd / || exit 2
|
|
73
|
+
exec 3<&0 || exit 2
|
|
74
|
+
/usr/bin/env -i HOME="$3" PATH="$4" "$2" --config=/dev/null --no-env-file --no-macros --no-install --eval ${quote(program)} "$1" <&3 3<&- 2>/dev/null &
|
|
75
|
+
runner=$!
|
|
76
|
+
exec 3<&-
|
|
77
|
+
(trap 'kill "$sleeper" 2>/dev/null; exit 0' TERM; /bin/sleep 6 & sleeper=$!; wait "$sleeper"; kill -KILL "$runner" 2>/dev/null) &
|
|
78
|
+
watchdog=$!
|
|
79
|
+
wait "$runner"; result=$?
|
|
80
|
+
kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null
|
|
81
|
+
if [ "$result" -ne 0 ]; then printf '%s\\n' 'Native safety guard refused: verification or execution failed.' >&2; exit 2; fi`;
|
|
82
|
+
var prefix = `/usr/bin/env -i /bin/sh -c ${quote(shell)} hasna-native-safety-v1 `;
|
|
83
|
+
function validateBinding(b) {
|
|
84
|
+
if (!b || !isNativeSafetyName(b.name) || ![b.runtime, b.worker, b.home].every((p) => typeof p === "string" && isAbsolute(p) && !/[\0\r\n]/.test(p)) || ![b.runtimeSHA256, b.workerSHA256].every((h) => /^[0-9a-f]{64}$/.test(h)) || typeof b.path !== "string" || b.path.split(":").some((p) => !isAbsolute(p) || /[\0\r\n]/.test(p)))
|
|
85
|
+
throw new Error("Invalid native safety binding");
|
|
86
|
+
}
|
|
87
|
+
function buildNativeSafetyCommand(binding) {
|
|
88
|
+
validateBinding(binding);
|
|
89
|
+
const encoded = Buffer.from(JSON.stringify(binding)).toString("base64url");
|
|
90
|
+
return prefix + [encoded, binding.runtime, binding.home, binding.path].map(quote).join(" ");
|
|
91
|
+
}
|
|
92
|
+
function nativeSafetyCommandBinding(command) {
|
|
93
|
+
if (!command.startsWith(prefix))
|
|
94
|
+
return;
|
|
95
|
+
const encoded = command.slice(prefix.length).match(/^'([A-Za-z0-9_-]+)' /)?.[1];
|
|
96
|
+
if (!encoded || encoded.length > 16384)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
const binding = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
100
|
+
return buildNativeSafetyCommand(binding) === command ? binding : undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function pinEligibleFile(file, maximum, executable = false) {
|
|
106
|
+
if (!isAbsolute(file) || realpathSync(file) !== file)
|
|
107
|
+
throw new Error("Native safety requires canonical files");
|
|
108
|
+
for (let parent = dirname(file);; ) {
|
|
109
|
+
const s = lstatSync(parent);
|
|
110
|
+
if (!s.isDirectory() || ![0, process.getuid()].includes(s.uid) || s.mode & 18)
|
|
111
|
+
throw new Error("Native safety requires trusted, non-writable file ancestry");
|
|
112
|
+
if (parent === dirname(parent))
|
|
113
|
+
break;
|
|
114
|
+
parent = dirname(parent);
|
|
115
|
+
}
|
|
116
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
117
|
+
try {
|
|
118
|
+
const s = fstatSync(fd);
|
|
119
|
+
if (!s.isFile() || ![0, process.getuid()].includes(s.uid) || s.mode & 18 || s.size > maximum || executable && !(s.mode & 64))
|
|
120
|
+
throw new Error("Native safety requires an eligible installed file");
|
|
121
|
+
const bytes = readFileSync(fd);
|
|
122
|
+
if (bytes.length > maximum)
|
|
123
|
+
throw new Error("Native safety file exceeds its bound");
|
|
124
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function installedNativeSafetyCommand(name) {
|
|
130
|
+
const home = realpathSync(homedir());
|
|
131
|
+
const runtime = realpathSync(process.execPath);
|
|
132
|
+
const candidates = [join(import.meta.dir, "../../bin/native-safety-entry.js"), join(import.meta.dir, "../bin/native-safety-entry.js"), join(import.meta.dir, "native-safety-entry.js")];
|
|
133
|
+
const worker = candidates.find(existsSync);
|
|
134
|
+
if (!worker)
|
|
135
|
+
throw new Error("Bundled native safety runner is missing; build or reinstall @hasna/hooks before installing its guards.");
|
|
136
|
+
const canonicalWorker = realpathSync(worker);
|
|
137
|
+
return buildNativeSafetyCommand({ name, runtime, runtimeSHA256: pinEligibleFile(runtime, 200000000, true), worker: canonicalWorker, workerSHA256: pinEligibleFile(canonicalWorker, 256000), home, path: `${dirname(runtime)}:${join(home, ".bun/bin")}:/usr/local/bin:/usr/bin:/bin` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/native-safety.ts
|
|
141
|
+
class NativeSafetyError extends Error {
|
|
142
|
+
code;
|
|
143
|
+
reason;
|
|
144
|
+
constructor(code, reason) {
|
|
145
|
+
super(`Native deletion safety refused (${code}). Run the reviewed Hooks safety setup before using shell or patch tools.`);
|
|
146
|
+
this.code = code;
|
|
147
|
+
this.reason = reason;
|
|
148
|
+
this.name = "NativeSafetyError";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
var executionSchema = "hasna.hooks.execution.v1";
|
|
152
|
+
var hash = (value) => createHash2("sha256").update(value).digest("hex");
|
|
153
|
+
var filename = "sumi-trash-guard.json";
|
|
154
|
+
var owner = (uid) => uid === 0 || uid === process.getuid?.();
|
|
155
|
+
function safeDirectory(path) {
|
|
156
|
+
for (let cursor = path;; cursor = dirname2(cursor)) {
|
|
157
|
+
const stat = lstatSync2(cursor);
|
|
158
|
+
if (!stat.isDirectory() || !owner(stat.uid) || stat.mode & 18)
|
|
159
|
+
throw new NativeSafetyError("binding_invalid");
|
|
160
|
+
if (dirname2(cursor) === cursor)
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function canonicalHome(home) {
|
|
165
|
+
if (!isAbsolute2(home) || home.includes("\x00") || realpathSync2(home) !== resolve(home))
|
|
166
|
+
throw new NativeSafetyError("binding_invalid");
|
|
167
|
+
safeDirectory(home);
|
|
168
|
+
return resolve(home);
|
|
169
|
+
}
|
|
170
|
+
function readOwned(path) {
|
|
171
|
+
safeDirectory(dirname2(path));
|
|
172
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
173
|
+
try {
|
|
174
|
+
const stat = fstatSync2(fd);
|
|
175
|
+
if (!stat.isFile() || !owner(stat.uid) || stat.mode & 63 || stat.size > 32768)
|
|
176
|
+
throw new NativeSafetyError("binding_invalid");
|
|
177
|
+
const value = readFileSync2(fd, "utf8");
|
|
178
|
+
if (Buffer.byteLength(value) > 32768)
|
|
179
|
+
throw new NativeSafetyError("binding_invalid");
|
|
180
|
+
return value;
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync2(fd);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function parseRecord(text, home) {
|
|
186
|
+
const record = JSON.parse(text);
|
|
187
|
+
if (!record || Array.isArray(record) || Object.keys(record).sort().join() !== "command,harness,schema" || record.schema !== "hasna.hooks.native-safety.v1" || record.harness !== "sumi" || typeof record.command !== "string")
|
|
188
|
+
throw new NativeSafetyError("binding_invalid");
|
|
189
|
+
const binding = nativeSafetyCommandBinding(record.command);
|
|
190
|
+
if (binding?.name !== "trash-guard" || binding.home !== home)
|
|
191
|
+
throw new NativeSafetyError("binding_invalid");
|
|
192
|
+
return record;
|
|
193
|
+
}
|
|
194
|
+
function syncDirectory(path) {
|
|
195
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_DIRECTORY | constants2.O_NOFOLLOW);
|
|
196
|
+
try {
|
|
197
|
+
fsyncSync(fd);
|
|
198
|
+
} finally {
|
|
199
|
+
closeSync2(fd);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function readNativeSafetyRegistration(home = homedir2()) {
|
|
203
|
+
try {
|
|
204
|
+
home = canonicalHome(home);
|
|
205
|
+
const path = join2(home, ".hasna/hooks/native", filename), text = readOwned(path);
|
|
206
|
+
return { record: parseRecord(text, home), sha256: hash(text), path };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof NativeSafetyError)
|
|
209
|
+
throw error;
|
|
210
|
+
throw new NativeSafetyError(error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function registerNativeSafety(options = {}) {
|
|
214
|
+
const home = canonicalHome(options.home ?? homedir2());
|
|
215
|
+
const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
|
|
216
|
+
const text = JSON.stringify(record) + `
|
|
217
|
+
`;
|
|
218
|
+
parseRecord(text, home);
|
|
219
|
+
for (const dir of [join2(home, ".hasna"), join2(home, ".hasna/hooks"), join2(home, ".hasna/hooks/native")]) {
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(dir, { mode: 448 });
|
|
222
|
+
syncDirectory(dirname2(dir));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code !== "EEXIST")
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
safeDirectory(dir);
|
|
228
|
+
}
|
|
229
|
+
const directory = join2(home, ".hasna/hooks/native"), path = join2(directory, filename), lock = path + ".lock";
|
|
230
|
+
let lockFD;
|
|
231
|
+
try {
|
|
232
|
+
lockFD = openSync2(lock, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
233
|
+
} catch {
|
|
234
|
+
throw new NativeSafetyError("binding_busy");
|
|
235
|
+
}
|
|
236
|
+
const lockIdentity = fstatSync2(lockFD);
|
|
237
|
+
let temporaryIdentity;
|
|
238
|
+
const temporary = join2(directory, `.${filename}.${randomUUID()}`);
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(lockFD, JSON.stringify({ pid: process.pid, expectedSHA256: options.expectedSHA256 ?? "absent" }));
|
|
241
|
+
fsyncSync(lockFD);
|
|
242
|
+
syncDirectory(directory);
|
|
243
|
+
let before;
|
|
244
|
+
try {
|
|
245
|
+
before = readNativeSafetyRegistration(home);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!(error instanceof NativeSafetyError) || error.code !== "binding_missing")
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
if (before?.sha256 === hash(text))
|
|
251
|
+
return { ok: true, changed: false, path, sha256: before.sha256 };
|
|
252
|
+
if (before ? options.expectedSHA256 !== before.sha256 : options.expectedSHA256 !== undefined && options.expectedSHA256 !== "absent")
|
|
253
|
+
throw new NativeSafetyError("binding_changed");
|
|
254
|
+
if (before) {
|
|
255
|
+
const backup = path + ".before-" + before.sha256;
|
|
256
|
+
if (existsSync2(backup)) {
|
|
257
|
+
if (hash(readOwned(backup)) !== before.sha256)
|
|
258
|
+
throw new NativeSafetyError("binding_changed");
|
|
259
|
+
} else {
|
|
260
|
+
const original = readOwned(path);
|
|
261
|
+
if (hash(original) !== before.sha256)
|
|
262
|
+
throw new NativeSafetyError("binding_changed");
|
|
263
|
+
const fd2 = openSync2(backup, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(fd2, original);
|
|
266
|
+
fsyncSync(fd2);
|
|
267
|
+
} finally {
|
|
268
|
+
closeSync2(fd2);
|
|
269
|
+
}
|
|
270
|
+
syncDirectory(directory);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const fd = openSync2(temporary, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
274
|
+
try {
|
|
275
|
+
temporaryIdentity = fstatSync2(fd);
|
|
276
|
+
writeFileSync(fd, text);
|
|
277
|
+
fsyncSync(fd);
|
|
278
|
+
} finally {
|
|
279
|
+
closeSync2(fd);
|
|
280
|
+
}
|
|
281
|
+
if (before) {
|
|
282
|
+
if (hash(readOwned(path)) !== before.sha256)
|
|
283
|
+
throw new NativeSafetyError("binding_changed");
|
|
284
|
+
renameSync(temporary, path);
|
|
285
|
+
} else {
|
|
286
|
+
linkSync(temporary, path);
|
|
287
|
+
unlinkSync(temporary);
|
|
288
|
+
}
|
|
289
|
+
syncDirectory(directory);
|
|
290
|
+
const after = readNativeSafetyRegistration(home);
|
|
291
|
+
if (after.sha256 !== hash(text))
|
|
292
|
+
throw new NativeSafetyError("binding_changed");
|
|
293
|
+
return { ok: true, changed: true, path, sha256: after.sha256 };
|
|
294
|
+
} finally {
|
|
295
|
+
closeSync2(lockFD);
|
|
296
|
+
const currentTemporary = lstatSync2(temporary, { throwIfNoEntry: false });
|
|
297
|
+
if (temporaryIdentity && currentTemporary?.dev === temporaryIdentity.dev && currentTemporary.ino === temporaryIdentity.ino)
|
|
298
|
+
unlinkSync(temporary);
|
|
299
|
+
const currentLock = lstatSync2(lock, { throwIfNoEntry: false });
|
|
300
|
+
if (currentLock?.dev !== lockIdentity.dev || currentLock.ino !== lockIdentity.ino)
|
|
301
|
+
throw new NativeSafetyError("binding_changed");
|
|
302
|
+
unlinkSync(lock);
|
|
303
|
+
syncDirectory(directory);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function evaluateNativeSafety(event, options = {}) {
|
|
307
|
+
return evaluateRecord(event, readNativeSafetyRegistration(options.home).record, options.signal);
|
|
308
|
+
}
|
|
309
|
+
async function evaluateNativeSafetyForExecution(event, options) {
|
|
310
|
+
if (!options || typeof options.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(options.agent))
|
|
311
|
+
throw new NativeSafetyError("input_invalid");
|
|
312
|
+
let snapshot;
|
|
313
|
+
try {
|
|
314
|
+
snapshot = JSON.parse(JSON.stringify(event));
|
|
315
|
+
} catch {
|
|
316
|
+
throw new NativeSafetyError("input_invalid");
|
|
317
|
+
}
|
|
318
|
+
if (snapshot?.tool_input && Object.hasOwn(snapshot.tool_input, "hasna_execution"))
|
|
319
|
+
throw new NativeSafetyError("input_invalid");
|
|
320
|
+
const before = readNativeSafetyRegistration(options.home);
|
|
321
|
+
const binding = nativeSafetyCommandBinding(before.record.command);
|
|
322
|
+
const decision = await evaluateRecord(snapshot, before.record, options.signal, options.agent);
|
|
323
|
+
if (readNativeSafetyRegistration(options.home).sha256 !== before.sha256)
|
|
324
|
+
throw new NativeSafetyError("binding_changed");
|
|
325
|
+
return decision.decision === "rewrite" ? {
|
|
326
|
+
decision: "rewrite",
|
|
327
|
+
execution: { command: decision.command, shell: "/bin/sh", cwd: snapshot.cwd, env: { HOME: binding.home, PATH: binding.path } }
|
|
328
|
+
} : decision;
|
|
329
|
+
}
|
|
330
|
+
async function evaluateRecord(event, record, signal, executionAgent) {
|
|
331
|
+
if (!event || event.hook_event_name !== "PreToolUse" || !["Bash", "apply_patch"].includes(event.tool_name) || typeof event.cwd !== "string" || !isAbsolute2(event.cwd) || !event.tool_input || typeof event.tool_input !== "object" || Array.isArray(event.tool_input))
|
|
332
|
+
throw new NativeSafetyError("input_invalid");
|
|
333
|
+
const input = JSON.stringify(executionAgent === undefined ? event : { ...event, hasna_execution: { schema: executionSchema, agent: executionAgent } });
|
|
334
|
+
if (Buffer.byteLength(input) > 1048576)
|
|
335
|
+
throw new NativeSafetyError("input_invalid");
|
|
336
|
+
const output = await new Promise((accept, reject) => {
|
|
337
|
+
const child = execFile("/bin/sh", ["-c", record.command], { cwd: "/", env: {}, encoding: "utf8", timeout: 8000, maxBuffer: 1048576, signal }, (error, stdout, stderr) => {
|
|
338
|
+
if (error || stderr) {
|
|
339
|
+
reject(new NativeSafetyError("guard_failed"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
accept(stdout);
|
|
343
|
+
});
|
|
344
|
+
child.stdin?.on("error", () => {});
|
|
345
|
+
child.stdin?.end(input);
|
|
346
|
+
});
|
|
347
|
+
if (!output)
|
|
348
|
+
return { decision: "continue" };
|
|
349
|
+
try {
|
|
350
|
+
const value = JSON.parse(output);
|
|
351
|
+
if (Object.keys(value).join() !== "hookSpecificOutput")
|
|
352
|
+
throw new Error;
|
|
353
|
+
const verdict = value.hookSpecificOutput;
|
|
354
|
+
if (verdict.hookEventName !== "PreToolUse")
|
|
355
|
+
throw new Error;
|
|
356
|
+
if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
|
|
357
|
+
return { decision: "deny", reason: verdict.permissionDecisionReason };
|
|
358
|
+
if (event.tool_name === "Bash" && verdict.permissionDecision === "allow" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,updatedInput" && typeof verdict.updatedInput?.command === "string" && verdict.updatedInput.command.trim() && (executionAgent === undefined || verdict.updatedInput.hasna_execution === executionSchema))
|
|
359
|
+
return { decision: "rewrite", command: verdict.updatedInput.command };
|
|
360
|
+
} catch {}
|
|
361
|
+
throw new NativeSafetyError("guard_failed");
|
|
362
|
+
}
|
|
363
|
+
async function verifyNativeSafetyCommand(command, home = homedir2()) {
|
|
364
|
+
home = canonicalHome(home);
|
|
365
|
+
const record = parseRecord(JSON.stringify({ schema: "hasna.hooks.native-safety.v1", harness: "sumi", command }), home);
|
|
366
|
+
const verdict = await evaluateRecord({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: home, tool_input: { command: "rm -- .hasna-trash-verification-only" } }, record);
|
|
367
|
+
if (verdict.decision !== "rewrite")
|
|
368
|
+
throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
|
|
369
|
+
}
|
|
370
|
+
async function verifyNativeSafetyRegistration(home = homedir2()) {
|
|
371
|
+
const before = readNativeSafetyRegistration(home);
|
|
372
|
+
await verifyNativeSafetyCommand(before.record.command, home);
|
|
373
|
+
const after = readNativeSafetyRegistration(home);
|
|
374
|
+
if (after.sha256 !== before.sha256)
|
|
375
|
+
throw new NativeSafetyError("binding_changed");
|
|
376
|
+
return { path: before.path, sha256: before.sha256 };
|
|
377
|
+
}
|
|
378
|
+
async function verifyNativeSafetyExecutionRegistration(home = homedir2()) {
|
|
379
|
+
const before = readNativeSafetyRegistration(home);
|
|
380
|
+
const verdict = await evaluateNativeSafetyForExecution({
|
|
381
|
+
hook_event_name: "PreToolUse",
|
|
382
|
+
tool_name: "Bash",
|
|
383
|
+
cwd: home,
|
|
384
|
+
tool_input: { command: "rm -- .hasna-trash-verification-only" }
|
|
385
|
+
}, { home, agent: "native-safety-verification" });
|
|
386
|
+
if (verdict.decision !== "rewrite")
|
|
387
|
+
throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
|
|
388
|
+
if (readNativeSafetyRegistration(home).sha256 !== before.sha256)
|
|
389
|
+
throw new NativeSafetyError("binding_changed");
|
|
390
|
+
return { path: before.path, sha256: before.sha256 };
|
|
391
|
+
}
|
|
392
|
+
export {
|
|
393
|
+
verifyNativeSafetyRegistration,
|
|
394
|
+
verifyNativeSafetyExecutionRegistration,
|
|
395
|
+
verifyNativeSafetyCommand,
|
|
396
|
+
registerNativeSafety,
|
|
397
|
+
readNativeSafetyRegistration,
|
|
398
|
+
evaluateNativeSafetyForExecution,
|
|
399
|
+
evaluateNativeSafety,
|
|
400
|
+
NativeSafetyError
|
|
401
|
+
};
|
|
@@ -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
|
+
}
|