@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.
@@ -47,9 +47,9 @@
47
47
  */
48
48
 
49
49
  import { homedir } from "os";
50
- import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
51
- import { readFileSync, realpathSync, statSync } from "fs";
52
- import { spawnSync } from "node:child_process";
50
+ import { isAbsolute, join, normalize, resolve, sep } from "path";
51
+ import { BINARY_FAILURES, findTrashBinary, inspectTrashBinary, type TrashBinaryInspection } from "./binary.js";
52
+ export { findTrashBinary, inspectTrashBinary } from "./binary.js";
53
53
  import {
54
54
  SYSTEM_PROTECTED_ROOTS,
55
55
  getCommand,
@@ -876,42 +876,6 @@ function gitHasDryRun(segment: Segment, verbIndex: number): boolean {
876
876
  /* Rewrite */
877
877
  /* ------------------------------------------------------------------ */
878
878
 
879
- /**
880
- * A command called trash may be Apple's unrelated system utility. Resolve
881
- * package provenance before executing a bounded, credential-free identity
882
- * probe. Never invoke an unknown executable merely to discover what it is.
883
- */
884
- export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
885
- const pathValue = env.PATH ?? "";
886
- const deadline = Date.now() + 2_000;
887
- for (const dir of pathValue.split(":").slice(0, 64)) {
888
- if (!isAbsolute(dir) || Date.now() >= deadline) continue;
889
- const candidate = resolve(dir, "trash");
890
- try {
891
- const stat = statSync(candidate);
892
- if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) continue;
893
- const executable = realpathSync(candidate);
894
- const packageRoot = resolve(dirname(executable), "../..");
895
- const manifestPath = join(packageRoot, "package.json");
896
- const manifestStat = statSync(manifestPath);
897
- if (!manifestStat.isFile() || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0) continue;
898
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
899
- if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
900
- typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) continue;
901
- const probe = spawnSync(candidate, ["--identity"], {
902
- encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
903
- env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
904
- });
905
- if (probe.error || probe.status !== 0) continue;
906
- const identity = JSON.parse(probe.stdout);
907
- if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
908
- identity.guardProtocol === "hasna.trash.guard.v1") return candidate;
909
- } catch {
910
- // Missing, unrelated, malformed or unresponsive packages are not targets.
911
- }
912
- }
913
- return null;
914
- }
915
879
 
916
880
  function shellQuoteWord(word: string): string {
917
881
  if (/^[A-Za-z0-9_\-./+=:,@%^]+$/.test(word)) return word;
@@ -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 = `${shellQuoteWord(trashPath)} guard`;
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
- const rewritten = rewriteCommand(command, scanned.rmHits, trashPath);
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",
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(process.execPath, [cli, "safety", "install", "trash-guard", "--target", "codex"], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
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(process.execPath, [probe], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
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);