@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
package/dist/index.js
CHANGED
|
@@ -581,7 +581,7 @@ var init_redact = __esm(() => {
|
|
|
581
581
|
];
|
|
582
582
|
});
|
|
583
583
|
|
|
584
|
-
//
|
|
584
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
|
|
585
585
|
var util, objectUtil, ZodParsedType, getParsedType = (data) => {
|
|
586
586
|
const t = typeof data;
|
|
587
587
|
switch (t) {
|
|
@@ -712,7 +712,7 @@ var init_util = __esm(() => {
|
|
|
712
712
|
]);
|
|
713
713
|
});
|
|
714
714
|
|
|
715
|
-
//
|
|
715
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/ZodError.js
|
|
716
716
|
var ZodIssueCode, quotelessJson = (obj) => {
|
|
717
717
|
const json = JSON.stringify(obj, null, 2);
|
|
718
718
|
return json.replace(/"([^"]+)":/g, "$1:");
|
|
@@ -833,7 +833,7 @@ var init_ZodError = __esm(() => {
|
|
|
833
833
|
};
|
|
834
834
|
});
|
|
835
835
|
|
|
836
|
-
//
|
|
836
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/locales/en.js
|
|
837
837
|
var errorMap = (issue, _ctx) => {
|
|
838
838
|
let message;
|
|
839
839
|
switch (issue.code) {
|
|
@@ -940,7 +940,7 @@ var init_en = __esm(() => {
|
|
|
940
940
|
en_default = errorMap;
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
-
//
|
|
943
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/errors.js
|
|
944
944
|
function setErrorMap(map) {
|
|
945
945
|
overrideErrorMap = map;
|
|
946
946
|
}
|
|
@@ -953,7 +953,7 @@ var init_errors = __esm(() => {
|
|
|
953
953
|
overrideErrorMap = en_default;
|
|
954
954
|
});
|
|
955
955
|
|
|
956
|
-
//
|
|
956
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
|
|
957
957
|
function addIssueToContext(ctx, issueData) {
|
|
958
958
|
const overrideMap = getErrorMap();
|
|
959
959
|
const issue = makeIssue({
|
|
@@ -1058,10 +1058,10 @@ var init_parseUtil = __esm(() => {
|
|
|
1058
1058
|
});
|
|
1059
1059
|
});
|
|
1060
1060
|
|
|
1061
|
-
//
|
|
1061
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/typeAliases.js
|
|
1062
1062
|
var init_typeAliases = () => {};
|
|
1063
1063
|
|
|
1064
|
-
//
|
|
1064
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js
|
|
1065
1065
|
var errorUtil;
|
|
1066
1066
|
var init_errorUtil = __esm(() => {
|
|
1067
1067
|
(function(errorUtil2) {
|
|
@@ -1070,7 +1070,7 @@ var init_errorUtil = __esm(() => {
|
|
|
1070
1070
|
})(errorUtil || (errorUtil = {}));
|
|
1071
1071
|
});
|
|
1072
1072
|
|
|
1073
|
-
//
|
|
1073
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/types.js
|
|
1074
1074
|
class ParseInputLazyPath {
|
|
1075
1075
|
constructor(parent, value, path, key) {
|
|
1076
1076
|
this._cachedPath = [];
|
|
@@ -4421,7 +4421,7 @@ var init_types = __esm(() => {
|
|
|
4421
4421
|
NEVER = INVALID;
|
|
4422
4422
|
});
|
|
4423
4423
|
|
|
4424
|
-
//
|
|
4424
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
4425
4425
|
var exports_external = {};
|
|
4426
4426
|
__export(exports_external, {
|
|
4427
4427
|
void: () => voidType,
|
|
@@ -4541,7 +4541,7 @@ var init_external = __esm(() => {
|
|
|
4541
4541
|
init_ZodError();
|
|
4542
4542
|
});
|
|
4543
4543
|
|
|
4544
|
-
//
|
|
4544
|
+
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/index.js
|
|
4545
4545
|
var init_zod = __esm(() => {
|
|
4546
4546
|
init_external();
|
|
4547
4547
|
init_external();
|
|
@@ -5101,7 +5101,7 @@ var init_profiles = __esm(() => {
|
|
|
5101
5101
|
init_app_home();
|
|
5102
5102
|
});
|
|
5103
5103
|
|
|
5104
|
-
//
|
|
5104
|
+
// ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/node_modules/@hasna/contracts/dist/client/transport.js
|
|
5105
5105
|
import { isIP } from "net";
|
|
5106
5106
|
import { spawnSync } from "child_process";
|
|
5107
5107
|
import { closeSync as closeSync22, fstatSync as fstatSync22, openSync as openSync22, readFileSync as readFileSync22 } from "fs";
|
|
@@ -7719,6 +7719,9 @@ function buildNativeSafetyCommand(binding) {
|
|
|
7719
7719
|
return prefix + [encoded, binding.runtime, binding.home, binding.path].map(quote).join(" ");
|
|
7720
7720
|
}
|
|
7721
7721
|
function nativeSafetyRegistration(command) {
|
|
7722
|
+
return nativeSafetyCommandBinding(command)?.name;
|
|
7723
|
+
}
|
|
7724
|
+
function nativeSafetyCommandBinding(command) {
|
|
7722
7725
|
if (!command.startsWith(prefix))
|
|
7723
7726
|
return;
|
|
7724
7727
|
const encoded = command.slice(prefix.length).match(/^'([A-Za-z0-9_-]+)' /)?.[1];
|
|
@@ -7726,7 +7729,7 @@ function nativeSafetyRegistration(command) {
|
|
|
7726
7729
|
return;
|
|
7727
7730
|
try {
|
|
7728
7731
|
const binding = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
7729
|
-
return buildNativeSafetyCommand(binding) === command ? binding
|
|
7732
|
+
return buildNativeSafetyCommand(binding) === command ? binding : undefined;
|
|
7730
7733
|
} catch {
|
|
7731
7734
|
return;
|
|
7732
7735
|
}
|
|
@@ -7920,7 +7923,7 @@ function writeCodexSettings(path, settings) {
|
|
|
7920
7923
|
// src/lib/claude-skills-coordination.ts
|
|
7921
7924
|
import { createHash as createHash5 } from "crypto";
|
|
7922
7925
|
|
|
7923
|
-
//
|
|
7926
|
+
// ../../node_modules/.bun/@hasna+skills@0.9.14+a00d3064559565bb/node_modules/@hasna/skills/dist/index.js
|
|
7924
7927
|
import { existsSync as existsSync9 } from "fs";
|
|
7925
7928
|
import { homedir as homedir4 } from "os";
|
|
7926
7929
|
import { join as join8, resolve as resolve3 } from "path";
|
|
@@ -11,5 +11,6 @@ export interface NativeSafetyBinding {
|
|
|
11
11
|
export declare function isNativeSafetyName(name: string): name is NativeSafetyName;
|
|
12
12
|
export declare function buildNativeSafetyCommand(binding: NativeSafetyBinding): string;
|
|
13
13
|
export declare function nativeSafetyRegistration(command: string): NativeSafetyName | undefined;
|
|
14
|
+
export declare function nativeSafetyCommandBinding(command: string): NativeSafetyBinding | undefined;
|
|
14
15
|
/** Built-in capability only: no registry, custom source, profile or app store. */
|
|
15
16
|
export declare function installedNativeSafetyCommand(name: NativeSafetyName): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare class NativeSafetyError extends Error {
|
|
2
|
+
readonly code: "binding_missing" | "binding_invalid" | "binding_changed" | "binding_busy" | "guard_failed" | "input_invalid";
|
|
3
|
+
readonly reason?: string | undefined;
|
|
4
|
+
constructor(code: "binding_missing" | "binding_invalid" | "binding_changed" | "binding_busy" | "guard_failed" | "input_invalid", reason?: string | undefined);
|
|
5
|
+
}
|
|
6
|
+
export type NativeSafetyEvent = {
|
|
7
|
+
hook_event_name: "PreToolUse";
|
|
8
|
+
tool_name: "Bash" | "apply_patch";
|
|
9
|
+
cwd: string;
|
|
10
|
+
session_id?: string;
|
|
11
|
+
tool_input: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
export type NativeSafetyDecision = {
|
|
14
|
+
decision: "continue";
|
|
15
|
+
} | {
|
|
16
|
+
decision: "deny";
|
|
17
|
+
reason: string;
|
|
18
|
+
} | {
|
|
19
|
+
decision: "rewrite";
|
|
20
|
+
command: string;
|
|
21
|
+
};
|
|
22
|
+
export type NativeSafetyRecord = {
|
|
23
|
+
schema: "hasna.hooks.native-safety.v1";
|
|
24
|
+
harness: "sumi";
|
|
25
|
+
command: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function readNativeSafetyRegistration(home?: string): {
|
|
28
|
+
record: NativeSafetyRecord;
|
|
29
|
+
sha256: string;
|
|
30
|
+
path: string;
|
|
31
|
+
};
|
|
32
|
+
/** Register only the bundled composite guard. Updates preserve the exact prior
|
|
33
|
+
* record and require its digest. A lock coordinates package-owned writers; an
|
|
34
|
+
* unexpected preimage or a retained interrupted lock requires reconciliation. */
|
|
35
|
+
export declare function registerNativeSafety(options?: {
|
|
36
|
+
home?: string;
|
|
37
|
+
expectedSHA256?: string;
|
|
38
|
+
command?: string;
|
|
39
|
+
}): {
|
|
40
|
+
ok: true;
|
|
41
|
+
changed: boolean;
|
|
42
|
+
path: string;
|
|
43
|
+
sha256: string;
|
|
44
|
+
};
|
|
45
|
+
/** A bounded supervisor subprocess evaluates data only; it never runs the
|
|
46
|
+
* requested shell command. AbortSignal targets this newly created child. */
|
|
47
|
+
export declare function evaluateNativeSafety(event: NativeSafetyEvent, options?: {
|
|
48
|
+
home?: string;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}): Promise<NativeSafetyDecision>;
|
|
51
|
+
/** Classify a synthetic removal through this exact supervisor without executing
|
|
52
|
+
* the returned command or creating a file. A harmless ':' would not test whether
|
|
53
|
+
* the saved PATH can actually resolve Trash. */
|
|
54
|
+
export declare function verifyNativeSafetyCommand(command: string, home?: string): Promise<void>;
|
|
55
|
+
export declare function verifyNativeSafetyRegistration(home?: string): Promise<{
|
|
56
|
+
path: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,363 @@
|
|
|
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 hash = (value) => createHash2("sha256").update(value).digest("hex");
|
|
152
|
+
var filename = "sumi-trash-guard.json";
|
|
153
|
+
var owner = (uid) => uid === 0 || uid === process.getuid?.();
|
|
154
|
+
function safeDirectory(path) {
|
|
155
|
+
for (let cursor = path;; cursor = dirname2(cursor)) {
|
|
156
|
+
const stat = lstatSync2(cursor);
|
|
157
|
+
if (!stat.isDirectory() || !owner(stat.uid) || stat.mode & 18)
|
|
158
|
+
throw new NativeSafetyError("binding_invalid");
|
|
159
|
+
if (dirname2(cursor) === cursor)
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function canonicalHome(home) {
|
|
164
|
+
if (!isAbsolute2(home) || home.includes("\x00") || realpathSync2(home) !== resolve(home))
|
|
165
|
+
throw new NativeSafetyError("binding_invalid");
|
|
166
|
+
safeDirectory(home);
|
|
167
|
+
return resolve(home);
|
|
168
|
+
}
|
|
169
|
+
function readOwned(path) {
|
|
170
|
+
safeDirectory(dirname2(path));
|
|
171
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
172
|
+
try {
|
|
173
|
+
const stat = fstatSync2(fd);
|
|
174
|
+
if (!stat.isFile() || !owner(stat.uid) || stat.mode & 63 || stat.size > 32768)
|
|
175
|
+
throw new NativeSafetyError("binding_invalid");
|
|
176
|
+
const value = readFileSync2(fd, "utf8");
|
|
177
|
+
if (Buffer.byteLength(value) > 32768)
|
|
178
|
+
throw new NativeSafetyError("binding_invalid");
|
|
179
|
+
return value;
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync2(fd);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function parseRecord(text, home) {
|
|
185
|
+
const record = JSON.parse(text);
|
|
186
|
+
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")
|
|
187
|
+
throw new NativeSafetyError("binding_invalid");
|
|
188
|
+
const binding = nativeSafetyCommandBinding(record.command);
|
|
189
|
+
if (binding?.name !== "trash-guard" || binding.home !== home)
|
|
190
|
+
throw new NativeSafetyError("binding_invalid");
|
|
191
|
+
return record;
|
|
192
|
+
}
|
|
193
|
+
function syncDirectory(path) {
|
|
194
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_DIRECTORY | constants2.O_NOFOLLOW);
|
|
195
|
+
try {
|
|
196
|
+
fsyncSync(fd);
|
|
197
|
+
} finally {
|
|
198
|
+
closeSync2(fd);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function readNativeSafetyRegistration(home = homedir2()) {
|
|
202
|
+
try {
|
|
203
|
+
home = canonicalHome(home);
|
|
204
|
+
const path = join2(home, ".hasna/hooks/native", filename), text = readOwned(path);
|
|
205
|
+
return { record: parseRecord(text, home), sha256: hash(text), path };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error instanceof NativeSafetyError)
|
|
208
|
+
throw error;
|
|
209
|
+
throw new NativeSafetyError(error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function registerNativeSafety(options = {}) {
|
|
213
|
+
const home = canonicalHome(options.home ?? homedir2());
|
|
214
|
+
const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
|
|
215
|
+
const text = JSON.stringify(record) + `
|
|
216
|
+
`;
|
|
217
|
+
parseRecord(text, home);
|
|
218
|
+
for (const dir of [join2(home, ".hasna"), join2(home, ".hasna/hooks"), join2(home, ".hasna/hooks/native")]) {
|
|
219
|
+
try {
|
|
220
|
+
mkdirSync(dir, { mode: 448 });
|
|
221
|
+
syncDirectory(dirname2(dir));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code !== "EEXIST")
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
safeDirectory(dir);
|
|
227
|
+
}
|
|
228
|
+
const directory = join2(home, ".hasna/hooks/native"), path = join2(directory, filename), lock = path + ".lock";
|
|
229
|
+
let lockFD;
|
|
230
|
+
try {
|
|
231
|
+
lockFD = openSync2(lock, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
232
|
+
} catch {
|
|
233
|
+
throw new NativeSafetyError("binding_busy");
|
|
234
|
+
}
|
|
235
|
+
const lockIdentity = fstatSync2(lockFD);
|
|
236
|
+
let temporaryIdentity;
|
|
237
|
+
const temporary = join2(directory, `.${filename}.${randomUUID()}`);
|
|
238
|
+
try {
|
|
239
|
+
writeFileSync(lockFD, JSON.stringify({ pid: process.pid, expectedSHA256: options.expectedSHA256 ?? "absent" }));
|
|
240
|
+
fsyncSync(lockFD);
|
|
241
|
+
syncDirectory(directory);
|
|
242
|
+
let before;
|
|
243
|
+
try {
|
|
244
|
+
before = readNativeSafetyRegistration(home);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!(error instanceof NativeSafetyError) || error.code !== "binding_missing")
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
if (before?.sha256 === hash(text))
|
|
250
|
+
return { ok: true, changed: false, path, sha256: before.sha256 };
|
|
251
|
+
if (before ? options.expectedSHA256 !== before.sha256 : options.expectedSHA256 !== undefined && options.expectedSHA256 !== "absent")
|
|
252
|
+
throw new NativeSafetyError("binding_changed");
|
|
253
|
+
if (before) {
|
|
254
|
+
const backup = path + ".before-" + before.sha256;
|
|
255
|
+
if (existsSync2(backup)) {
|
|
256
|
+
if (hash(readOwned(backup)) !== before.sha256)
|
|
257
|
+
throw new NativeSafetyError("binding_changed");
|
|
258
|
+
} else {
|
|
259
|
+
const original = readOwned(path);
|
|
260
|
+
if (hash(original) !== before.sha256)
|
|
261
|
+
throw new NativeSafetyError("binding_changed");
|
|
262
|
+
const fd2 = openSync2(backup, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(fd2, original);
|
|
265
|
+
fsyncSync(fd2);
|
|
266
|
+
} finally {
|
|
267
|
+
closeSync2(fd2);
|
|
268
|
+
}
|
|
269
|
+
syncDirectory(directory);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const fd = openSync2(temporary, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
273
|
+
try {
|
|
274
|
+
temporaryIdentity = fstatSync2(fd);
|
|
275
|
+
writeFileSync(fd, text);
|
|
276
|
+
fsyncSync(fd);
|
|
277
|
+
} finally {
|
|
278
|
+
closeSync2(fd);
|
|
279
|
+
}
|
|
280
|
+
if (before) {
|
|
281
|
+
if (hash(readOwned(path)) !== before.sha256)
|
|
282
|
+
throw new NativeSafetyError("binding_changed");
|
|
283
|
+
renameSync(temporary, path);
|
|
284
|
+
} else {
|
|
285
|
+
linkSync(temporary, path);
|
|
286
|
+
unlinkSync(temporary);
|
|
287
|
+
}
|
|
288
|
+
syncDirectory(directory);
|
|
289
|
+
const after = readNativeSafetyRegistration(home);
|
|
290
|
+
if (after.sha256 !== hash(text))
|
|
291
|
+
throw new NativeSafetyError("binding_changed");
|
|
292
|
+
return { ok: true, changed: true, path, sha256: after.sha256 };
|
|
293
|
+
} finally {
|
|
294
|
+
closeSync2(lockFD);
|
|
295
|
+
const currentTemporary = lstatSync2(temporary, { throwIfNoEntry: false });
|
|
296
|
+
if (temporaryIdentity && currentTemporary?.dev === temporaryIdentity.dev && currentTemporary.ino === temporaryIdentity.ino)
|
|
297
|
+
unlinkSync(temporary);
|
|
298
|
+
const currentLock = lstatSync2(lock, { throwIfNoEntry: false });
|
|
299
|
+
if (currentLock?.dev !== lockIdentity.dev || currentLock.ino !== lockIdentity.ino)
|
|
300
|
+
throw new NativeSafetyError("binding_changed");
|
|
301
|
+
unlinkSync(lock);
|
|
302
|
+
syncDirectory(directory);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function evaluateNativeSafety(event, options = {}) {
|
|
306
|
+
return evaluateRecord(event, readNativeSafetyRegistration(options.home).record, options.signal);
|
|
307
|
+
}
|
|
308
|
+
async function evaluateRecord(event, record, signal) {
|
|
309
|
+
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))
|
|
310
|
+
throw new NativeSafetyError("input_invalid");
|
|
311
|
+
const input = JSON.stringify(event);
|
|
312
|
+
if (Buffer.byteLength(input) > 1048576)
|
|
313
|
+
throw new NativeSafetyError("input_invalid");
|
|
314
|
+
const output = await new Promise((accept, reject) => {
|
|
315
|
+
const child = execFile("/bin/sh", ["-c", record.command], { cwd: "/", env: {}, encoding: "utf8", timeout: 8000, maxBuffer: 1048576, signal }, (error, stdout, stderr) => {
|
|
316
|
+
if (error || stderr) {
|
|
317
|
+
reject(new NativeSafetyError("guard_failed"));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
accept(stdout);
|
|
321
|
+
});
|
|
322
|
+
child.stdin?.on("error", () => {});
|
|
323
|
+
child.stdin?.end(input);
|
|
324
|
+
});
|
|
325
|
+
if (!output)
|
|
326
|
+
return { decision: "continue" };
|
|
327
|
+
try {
|
|
328
|
+
const value = JSON.parse(output);
|
|
329
|
+
if (Object.keys(value).join() !== "hookSpecificOutput")
|
|
330
|
+
throw new Error;
|
|
331
|
+
const verdict = value.hookSpecificOutput;
|
|
332
|
+
if (verdict.hookEventName !== "PreToolUse")
|
|
333
|
+
throw new Error;
|
|
334
|
+
if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
|
|
335
|
+
return { decision: "deny", reason: verdict.permissionDecisionReason };
|
|
336
|
+
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())
|
|
337
|
+
return { decision: "rewrite", command: verdict.updatedInput.command };
|
|
338
|
+
} catch {}
|
|
339
|
+
throw new NativeSafetyError("guard_failed");
|
|
340
|
+
}
|
|
341
|
+
async function verifyNativeSafetyCommand(command, home = homedir2()) {
|
|
342
|
+
home = canonicalHome(home);
|
|
343
|
+
const record = parseRecord(JSON.stringify({ schema: "hasna.hooks.native-safety.v1", harness: "sumi", command }), home);
|
|
344
|
+
const verdict = await evaluateRecord({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: home, tool_input: { command: "rm -- .hasna-trash-verification-only" } }, record);
|
|
345
|
+
if (verdict.decision !== "rewrite")
|
|
346
|
+
throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
|
|
347
|
+
}
|
|
348
|
+
async function verifyNativeSafetyRegistration(home = homedir2()) {
|
|
349
|
+
const before = readNativeSafetyRegistration(home);
|
|
350
|
+
await verifyNativeSafetyCommand(before.record.command, home);
|
|
351
|
+
const after = readNativeSafetyRegistration(home);
|
|
352
|
+
if (after.sha256 !== before.sha256)
|
|
353
|
+
throw new NativeSafetyError("binding_changed");
|
|
354
|
+
return { path: before.path, sha256: before.sha256 };
|
|
355
|
+
}
|
|
356
|
+
export {
|
|
357
|
+
verifyNativeSafetyRegistration,
|
|
358
|
+
verifyNativeSafetyCommand,
|
|
359
|
+
registerNativeSafety,
|
|
360
|
+
readNativeSafetyRegistration,
|
|
361
|
+
evaluateNativeSafety,
|
|
362
|
+
NativeSafetyError
|
|
363
|
+
};
|
package/dist/sdk/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
//
|
|
2
|
+
// ../../node_modules/.bun/@hasna+contracts@1.2.2+944b86d8024e79fa/node_modules/@hasna/contracts/dist/client/transport.js
|
|
3
3
|
import { isIP } from "net";
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
5
|
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
|