@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
|
@@ -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;
|
|
@@ -922,8 +886,11 @@ function shellQuoteWord(word: string): string {
|
|
|
922
886
|
* Replace the verb token of every owned `rm` with `<quoted trash> guard`,
|
|
923
887
|
* leaving every other byte of the command untouched.
|
|
924
888
|
*/
|
|
925
|
-
export function rewriteCommand(command: string, hits: RmHit[], trashPath: string): string {
|
|
926
|
-
const replacement =
|
|
889
|
+
export function rewriteCommand(command: string, hits: RmHit[], trashPath: string, execution?: GuardDependencies["execution"]): string {
|
|
890
|
+
const replacement = execution
|
|
891
|
+
? ["/usr/bin/env", "-i", `HOME=${execution.home}`, `PATH=${execution.path}`, `HASNA_TRASH_AGENT=${execution.agent}`,
|
|
892
|
+
execution.runtime, "--config=/dev/null", "--no-env-file", "--no-macros", "--no-install", trashPath, "guard"].map(shellQuoteWord).join(" ")
|
|
893
|
+
: `${shellQuoteWord(trashPath)} guard`;
|
|
927
894
|
const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
|
|
928
895
|
let out = command;
|
|
929
896
|
for (const hit of ordered) {
|
|
@@ -991,6 +958,25 @@ export interface GuardDependencies {
|
|
|
991
958
|
home: string;
|
|
992
959
|
cwd: string;
|
|
993
960
|
findTrash: (env?: NodeJS.ProcessEnv) => string | null;
|
|
961
|
+
inspectTrash?: (env?: NodeJS.ProcessEnv) => TrashBinaryInspection;
|
|
962
|
+
/** Only the verified native worker supplies this execution capability. */
|
|
963
|
+
execution?: { runtime: string; home: string; path: string; agent: string };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Environment-changing shell constructs could run before even /usr/bin/env.
|
|
967
|
+
* Refuse them in execution mode rather than pretending an outer clean env is
|
|
968
|
+
* enough. Ordinary command/control-flow/quoting stays with the existing lexer. */
|
|
969
|
+
function changesExecutionEnvironment(command: string): boolean {
|
|
970
|
+
const stateCommands = new Set(["export", "set", "unset", "readonly", "declare", "typeset", "local", "alias", "unalias", "read", "readarray", "mapfile", "getopts", "trap", "enable", "function", "env", "sudo", "doas", "stdbuf"]);
|
|
971
|
+
const parsed = lexCommand(command);
|
|
972
|
+
return parsed.segments.some((segment) => {
|
|
973
|
+
const index = commandWordIndex(segment);
|
|
974
|
+
const prefix = segment.words.slice(0, index < 0 ? segment.words.length : index);
|
|
975
|
+
if (prefix.some((word) => ASSIGNMENT_RE.test(decodeWord(word.text)) || stateCommands.has(baseVerb(decodeWord(word.text))))) return true;
|
|
976
|
+
if (index < 0) return false;
|
|
977
|
+
const word = segment.words[index]!;
|
|
978
|
+
return stateCommands.has(baseVerb(decodeWord(word.text))) || /^\s*\(\s*\)\s*\{/.test(command.slice(word.end));
|
|
979
|
+
});
|
|
994
980
|
}
|
|
995
981
|
|
|
996
982
|
function deny(reason: string): CodewithHookOutput {
|
|
@@ -1025,6 +1011,11 @@ function allowRewrite(updatedInput: Record<string, unknown>): CodewithHookOutput
|
|
|
1025
1011
|
*/
|
|
1026
1012
|
function resolveTrash(deps: GuardDependencies): { path: string | null; error: string | null } {
|
|
1027
1013
|
try {
|
|
1014
|
+
if (deps.inspectTrash) {
|
|
1015
|
+
const inspection = deps.inspectTrash();
|
|
1016
|
+
return inspection.ok ? { path: inspection.path, error: null }
|
|
1017
|
+
: { path: null, error: `${inspection.code}: ${BINARY_FAILURES[inspection.code]}` };
|
|
1018
|
+
}
|
|
1028
1019
|
return { path: deps.findTrash(), error: null };
|
|
1029
1020
|
} catch (cause) {
|
|
1030
1021
|
return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
|
|
@@ -1108,8 +1099,12 @@ export function evaluate(input: CodewithHookInput, deps: GuardDependencies): Cod
|
|
|
1108
1099
|
}
|
|
1109
1100
|
const trashPath = trash.path;
|
|
1110
1101
|
|
|
1111
|
-
|
|
1102
|
+
if (deps.execution && changesExecutionEnvironment(command)) {
|
|
1103
|
+
return deny("[trash-guard] A deletion combined with shell environment changes cannot use the verified native execution context. Run the deletion as a separate plain rm command.");
|
|
1104
|
+
}
|
|
1105
|
+
const rewritten = rewriteCommand(command, scanned.rmHits, trashPath, deps.execution);
|
|
1112
1106
|
const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
|
|
1107
|
+
if (deps.execution) updatedInput.hasna_execution = "hasna.hooks.execution.v1";
|
|
1113
1108
|
|
|
1114
1109
|
if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
|
|
1115
1110
|
// Never allow a partial rewrite: the harness falls back to the ORIGINAL
|
|
@@ -1137,7 +1132,7 @@ export async function run(): Promise<void> {
|
|
|
1137
1132
|
const command = getCommand(input);
|
|
1138
1133
|
try {
|
|
1139
1134
|
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
1140
|
-
const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary });
|
|
1135
|
+
const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary });
|
|
1141
1136
|
// Native Codex rejects `continue` in PreToolUse JSON. Silence is the
|
|
1142
1137
|
// documented no-op for both Codex and Claude; emit only actual decisions.
|
|
1143
1138
|
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
@@ -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
|
|
|
@@ -13,6 +13,14 @@ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" ||
|
|
|
13
13
|
|| !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
|
|
14
14
|
|| typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
|
|
15
15
|
if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
|
|
16
|
+
const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
|
|
17
|
+
if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
|
|
18
|
+
|| requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
|
|
19
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
|
|
20
|
+
const execution = requestedExecution === undefined ? undefined : {
|
|
21
|
+
runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
|
|
22
|
+
};
|
|
23
|
+
if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
|
|
16
24
|
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
|
|
17
25
|
if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
|
|
18
26
|
const patch = input.tool_input.command ?? input.tool_input.patch;
|
|
@@ -24,5 +32,5 @@ const handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.comma
|
|
|
24
32
|
const verdict = repos.decision === "block"
|
|
25
33
|
? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } }
|
|
26
34
|
: 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 };
|
|
35
|
+
: name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
|
|
28
36
|
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.5",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { copyFile, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
type PackageJson = {
|
|
@@ -227,10 +227,19 @@ async function validatePackedNativeSafety(packageDir: string, smokeDir: string):
|
|
|
227
227
|
const home = join(smokeDir, "native-safety-home");
|
|
228
228
|
await mkdir(home, { mode: 0o700 });
|
|
229
229
|
const settings = join(home, ".codex", "hooks.json");
|
|
230
|
+
// The supervisor retains its install-time runtime directory. A station's Bun
|
|
231
|
+
// directory may also contain Trash, so isolate the runtime for this negative
|
|
232
|
+
// control instead of inheriting the station's executable lookup.
|
|
233
|
+
const runtimeDir = join(smokeDir, "native-runtime");
|
|
234
|
+
await mkdir(runtimeDir, { mode: 0o700 });
|
|
235
|
+
const runtime = join(runtimeDir, "bun");
|
|
236
|
+
await copyFile(await realpath(process.execPath), runtime);
|
|
237
|
+
// An unrelated system trash utility remains subject to the package
|
|
238
|
+
// provenance check; only a verified @hasna/trash is a supported guard.
|
|
230
239
|
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, HASNA_STATION: "no-such-station", HASNA_HOOKS_CODEX_SETTINGS_PATH: settings };
|
|
231
240
|
for (const key of Object.keys(env)) if (/^(HASNA_HOOKS_|HOOKS_)/.test(key) && key !== "HASNA_HOOKS_CODEX_SETTINGS_PATH") delete env[key];
|
|
232
241
|
const cli = join(packageDir, "bin/index.js");
|
|
233
|
-
const installed = spawnSync(
|
|
242
|
+
const installed = spawnSync(runtime, [cli, "safety", "install", "trash-guard", "--target", "codex"], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
|
|
234
243
|
if (installed.status !== 0 || !JSON.parse(installed.stdout).success) throw new Error("Packed native safety registration failed");
|
|
235
244
|
if (existsSync(join(home, ".hasna", "hooks"))) throw new Error("Stateless safety registration created app state");
|
|
236
245
|
const native = JSON.parse(await readFile(settings, "utf8"));
|
|
@@ -243,7 +252,7 @@ async function validatePackedNativeSafety(packageDir: string, smokeDir: string):
|
|
|
243
252
|
if (refused.status !== 0 || JSON.parse(refused.stdout).hookSpecificOutput.permissionDecision !== "deny") throw new Error("Packed native missing-Trash refusal failed");
|
|
244
253
|
const probe = join(smokeDir, "native-registration.ts");
|
|
245
254
|
await writeFile(probe, `import { getRegisteredHooksForTarget } from "@hasna/hooks"; console.log(JSON.stringify(getRegisteredHooksForTarget("global", "codex")));`);
|
|
246
|
-
const listed = spawnSync(
|
|
255
|
+
const listed = spawnSync(runtime, [probe], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
|
|
247
256
|
if (listed.status !== 0 || JSON.stringify(JSON.parse(listed.stdout)) !== '["trash-guard"]') throw new Error("Packed SDK cannot recognize the compiled CLI safety registration");
|
|
248
257
|
const worker = join(packageDir, "bin/native-safety-entry.js");
|
|
249
258
|
const bytes = await readFile(worker);
|