@hasna/hooks 0.10.4 → 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 CHANGED
@@ -333,13 +333,27 @@ Trash script's Bun interpreter and all preceding PATH directories are checked
333
333
  before its identity probe runs; unsafe or unsupported interpreter resolution
334
334
  reports `unsafe_interpreter` without executing it.
335
335
 
336
- Native adapters import `@hasna/hooks/native-safety`, pass the final shell/patch
337
- operation to `evaluateNativeSafety`, and enforce its continue, rewrite or deny
338
- verdict before execution. Missing or unhealthy bindings reject the call. The
339
- adapter must preserve cancellation and prevent later hooks from changing admitted
340
- inputs. This covers supported shell removal and whole-file patch deletion; it
341
- does not claim universal interception of arbitrary filesystem writes or plugin
342
- executor replacements. Installing the record alone does not activate a harness.
336
+ Native adapters import `@hasna/hooks/native-safety` and pass the final shell/patch
337
+ operation to `evaluateNativeSafetyForExecution`, with cancellation and an explicit
338
+ agent label. A rewrite returns the complete command, shell, resolved cwd and
339
+ environment. The adapter must use those fields together, disable environment
340
+ inheritance, and prevent later hooks from changing them. Every rewritten removal
341
+ uses the verified absolute Bun runtime with a clean environment and project
342
+ configuration, env files, macros and installation disabled. Deletions combined
343
+ with shell environment changes are refused; run those removals separately.
344
+
345
+ Use `hooks safety verify --target sumi --execution` for a small, read-only check
346
+ that the saved worker supports this execution contract. A legacy worker that only
347
+ classifies commands cannot pass it. The compatible `evaluateNativeSafety` API and
348
+ verification without `--execution` classify commands only; their success does not
349
+ prove execution compatibility. Neither check runs the returned deletion, creates
350
+ a test file, authenticates Trash or proves native adoption.
351
+
352
+ The bounded agent label can identify the Sumi session in Trash's existing name
353
+ field; it does not invent unsupported harness/session fields. Missing or unhealthy
354
+ bindings reject the call. This covers supported shell removal and whole-file patch
355
+ deletion, not arbitrary filesystem writes or plugin executor replacements.
356
+ Installing the record alone does not activate a harness.
343
357
 
344
358
  ## Data Directory
345
359
 
package/bin/index.js CHANGED
@@ -44610,6 +44610,7 @@ class NativeSafetyError extends Error {
44610
44610
  this.name = "NativeSafetyError";
44611
44611
  }
44612
44612
  }
44613
+ var executionSchema = "hasna.hooks.execution.v1";
44613
44614
  var hash = (value) => createHash7("sha256").update(value).digest("hex");
44614
44615
  var filename = "sumi-trash-guard.json";
44615
44616
  var owner2 = (uid2) => uid2 === 0 || uid2 === process.getuid?.();
@@ -44764,10 +44765,31 @@ function registerNativeSafety(options = {}) {
44764
44765
  syncDirectory(directory2);
44765
44766
  }
44766
44767
  }
44767
- async function evaluateRecord(event, record3, signal) {
44768
+ async function evaluateNativeSafetyForExecution(event, options) {
44769
+ if (!options || typeof options.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(options.agent))
44770
+ throw new NativeSafetyError("input_invalid");
44771
+ let snapshot2;
44772
+ try {
44773
+ snapshot2 = JSON.parse(JSON.stringify(event));
44774
+ } catch {
44775
+ throw new NativeSafetyError("input_invalid");
44776
+ }
44777
+ if (snapshot2?.tool_input && Object.hasOwn(snapshot2.tool_input, "hasna_execution"))
44778
+ throw new NativeSafetyError("input_invalid");
44779
+ const before = readNativeSafetyRegistration(options.home);
44780
+ const binding = nativeSafetyCommandBinding(before.record.command);
44781
+ const decision = await evaluateRecord(snapshot2, before.record, options.signal, options.agent);
44782
+ if (readNativeSafetyRegistration(options.home).sha256 !== before.sha256)
44783
+ throw new NativeSafetyError("binding_changed");
44784
+ return decision.decision === "rewrite" ? {
44785
+ decision: "rewrite",
44786
+ execution: { command: decision.command, shell: "/bin/sh", cwd: snapshot2.cwd, env: { HOME: binding.home, PATH: binding.path } }
44787
+ } : decision;
44788
+ }
44789
+ async function evaluateRecord(event, record3, signal, executionAgent) {
44768
44790
  if (!event || event.hook_event_name !== "PreToolUse" || !["Bash", "apply_patch"].includes(event.tool_name) || typeof event.cwd !== "string" || !isAbsolute7(event.cwd) || !event.tool_input || typeof event.tool_input !== "object" || Array.isArray(event.tool_input))
44769
44791
  throw new NativeSafetyError("input_invalid");
44770
- const input2 = JSON.stringify(event);
44792
+ const input2 = JSON.stringify(executionAgent === undefined ? event : { ...event, hasna_execution: { schema: executionSchema, agent: executionAgent } });
44771
44793
  if (Buffer.byteLength(input2) > 1048576)
44772
44794
  throw new NativeSafetyError("input_invalid");
44773
44795
  const output2 = await new Promise((accept, reject) => {
@@ -44792,7 +44814,7 @@ async function evaluateRecord(event, record3, signal) {
44792
44814
  throw new Error;
44793
44815
  if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
44794
44816
  return { decision: "deny", reason: verdict.permissionDecisionReason };
44795
- 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())
44817
+ 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))
44796
44818
  return { decision: "rewrite", command: verdict.updatedInput.command };
44797
44819
  } catch {}
44798
44820
  throw new NativeSafetyError("guard_failed");
@@ -44812,6 +44834,20 @@ async function verifyNativeSafetyRegistration(home = homedir7()) {
44812
44834
  throw new NativeSafetyError("binding_changed");
44813
44835
  return { path: before.path, sha256: before.sha256 };
44814
44836
  }
44837
+ async function verifyNativeSafetyExecutionRegistration(home = homedir7()) {
44838
+ const before = readNativeSafetyRegistration(home);
44839
+ const verdict = await evaluateNativeSafetyForExecution({
44840
+ hook_event_name: "PreToolUse",
44841
+ tool_name: "Bash",
44842
+ cwd: home,
44843
+ tool_input: { command: "rm -- .hasna-trash-verification-only" }
44844
+ }, { home, agent: "native-safety-verification" });
44845
+ if (verdict.decision !== "rewrite")
44846
+ throw new NativeSafetyError("guard_failed", verdict.decision === "deny" ? verdict.reason.slice(0, 512) : undefined);
44847
+ if (readNativeSafetyRegistration(home).sha256 !== before.sha256)
44848
+ throw new NativeSafetyError("binding_changed");
44849
+ return { path: before.path, sha256: before.sha256 };
44850
+ }
44815
44851
 
44816
44852
  // hooks/hook-trash-guard/src/binary.ts
44817
44853
  import { dirname as dirname17, isAbsolute as isAbsolute15, join as join17, resolve as resolve17 } from "path";
@@ -46759,12 +46795,12 @@ safety.command("install <guard>").option("--target <target>", "codex, claude or
46759
46795
  if (!result.success)
46760
46796
  process.exitCode = 1;
46761
46797
  });
46762
- safety.command("verify").requiredOption("--target <target>", "sumi").description("Verify a required native binding and its supervisor without running a file operation").action(async (options) => {
46798
+ safety.command("verify").requiredOption("--target <target>", "sumi").option("--execution", "Also require the pinned native execution capability; no file operation is run").description("Verify a required native binding and its supervisor without running a file operation").action(async (options) => {
46763
46799
  try {
46764
46800
  if (options.target !== "sumi")
46765
46801
  throw new NativeSafetyError("input_invalid");
46766
- const current2 = await verifyNativeSafetyRegistration();
46767
- console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, nativeAdoptionVerified: false }));
46802
+ const current2 = await (options.execution ? verifyNativeSafetyExecutionRegistration() : verifyNativeSafetyRegistration());
46803
+ console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, ...options.execution ? { executionCapabilityVerified: true } : {}, nativeAdoptionVerified: false }));
46768
46804
  } catch (error61) {
46769
46805
  console.log(JSON.stringify({ ok: false, target: "sumi", code: error61 instanceof NativeSafetyError ? error61.code : "guard_failed", ...error61 instanceof NativeSafetyError && error61.reason ? { reason: error61.reason } : {} }));
46770
46806
  process.exitCode = 1;
@@ -911,8 +911,21 @@ function shellQuoteWord(word) {
911
911
  return word;
912
912
  return `'${word.replace(/'/g, `'\\''`)}'`;
913
913
  }
914
- function rewriteCommand(command, hits, trashPath) {
915
- const replacement = `${shellQuoteWord(trashPath)} guard`;
914
+ function rewriteCommand(command, hits, trashPath, execution) {
915
+ const replacement = execution ? [
916
+ "/usr/bin/env",
917
+ "-i",
918
+ `HOME=${execution.home}`,
919
+ `PATH=${execution.path}`,
920
+ `HASNA_TRASH_AGENT=${execution.agent}`,
921
+ execution.runtime,
922
+ "--config=/dev/null",
923
+ "--no-env-file",
924
+ "--no-macros",
925
+ "--no-install",
926
+ trashPath,
927
+ "guard"
928
+ ].map(shellQuoteWord).join(" ") : `${shellQuoteWord(trashPath)} guard`;
916
929
  const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
917
930
  let out = command;
918
931
  for (const hit of ordered) {
@@ -964,6 +977,20 @@ function updatedInputIsComplete(updated, original, hits, cwd, home) {
964
977
  return false;
965
978
  return rewriteIsComplete(original, updated.command, hits, cwd, home);
966
979
  }
980
+ function changesExecutionEnvironment(command) {
981
+ const stateCommands = new Set(["export", "set", "unset", "readonly", "declare", "typeset", "local", "alias", "unalias", "read", "readarray", "mapfile", "getopts", "trap", "enable", "function", "env", "sudo", "doas", "stdbuf"]);
982
+ const parsed = lexCommand(command);
983
+ return parsed.segments.some((segment) => {
984
+ const index = commandWordIndex(segment);
985
+ const prefix = segment.words.slice(0, index < 0 ? segment.words.length : index);
986
+ if (prefix.some((word2) => ASSIGNMENT_RE.test(decodeWord(word2.text)) || stateCommands.has(baseVerb(decodeWord(word2.text)))))
987
+ return true;
988
+ if (index < 0)
989
+ return false;
990
+ const word = segment.words[index];
991
+ return stateCommands.has(baseVerb(decodeWord(word.text))) || /^\s*\(\s*\)\s*\{/.test(command.slice(word.end));
992
+ });
993
+ }
967
994
  function deny(reason) {
968
995
  return {
969
996
  hookSpecificOutput: {
@@ -1042,8 +1069,13 @@ function evaluate(input, deps) {
1042
1069
  return deny(ABSENT_REASON);
1043
1070
  }
1044
1071
  const trashPath = trash.path;
1045
- const rewritten = rewriteCommand(command, scanned.rmHits, trashPath);
1072
+ if (deps.execution && changesExecutionEnvironment(command)) {
1073
+ 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.");
1074
+ }
1075
+ const rewritten = rewriteCommand(command, scanned.rmHits, trashPath, deps.execution);
1046
1076
  const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
1077
+ if (deps.execution)
1078
+ updatedInput.hasna_execution = "hasna.hooks.execution.v1";
1047
1079
  if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
1048
1080
  return deny("[trash-guard] The rewrite of this command did not verify (the delete could not be redirected cleanly), so the command is refused rather than run unredirected. Re-run the delete as a plain `rm -- <path>` command.");
1049
1081
  }
@@ -1379,6 +1411,17 @@ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" ||
1379
1411
  throw new Error("Invalid safety input");
1380
1412
  if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
1381
1413
  throw new Error("Invalid shell input");
1414
+ var requestedExecution = input.hasna_execution;
1415
+ if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema" || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent)))
1416
+ throw new Error("Invalid execution capability");
1417
+ var execution = requestedExecution === undefined ? undefined : {
1418
+ runtime: process.execPath,
1419
+ home: homedir2(),
1420
+ path: process.env.PATH,
1421
+ agent: requestedExecution.agent
1422
+ };
1423
+ if (execution && (!isAbsolute4(execution.runtime) || !execution.path))
1424
+ throw new Error("Invalid bound execution context");
1382
1425
  if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
1383
1426
  if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch)
1384
1427
  throw new Error("Conflicting patch aliases");
@@ -1389,5 +1432,5 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
1389
1432
  }
1390
1433
  var repos = evaluate2(input).output;
1391
1434
  var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
1392
- var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary }) : { continue: true };
1435
+ var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
1393
1436
  process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
@@ -19,6 +19,20 @@ export type NativeSafetyDecision = {
19
19
  decision: "rewrite";
20
20
  command: string;
21
21
  };
22
+ export type NativeSafetyExecutionDecision = Exclude<NativeSafetyDecision, {
23
+ decision: "rewrite";
24
+ }> | {
25
+ decision: "rewrite";
26
+ execution: {
27
+ command: string;
28
+ shell: "/bin/sh";
29
+ cwd: string;
30
+ env: {
31
+ HOME: string;
32
+ PATH: string;
33
+ };
34
+ };
35
+ };
22
36
  export type NativeSafetyRecord = {
23
37
  schema: "hasna.hooks.native-safety.v1";
24
38
  harness: "sumi";
@@ -48,6 +62,15 @@ export declare function evaluateNativeSafety(event: NativeSafetyEvent, options?:
48
62
  home?: string;
49
63
  signal?: AbortSignal;
50
64
  }): Promise<NativeSafetyDecision>;
65
+ /** Native tool dispatch consumes this entire context, with environment inheritance
66
+ * disabled. The worker pins each Trash invocation to its verified Bun runtime,
67
+ * ignoring caller PATH, preload variables and cwd Bun configuration. `agent` is
68
+ * the bounded explicit Trash label; it does not invent a harness/session enum. */
69
+ export declare function evaluateNativeSafetyForExecution(event: NativeSafetyEvent, options: {
70
+ home?: string;
71
+ signal?: AbortSignal;
72
+ agent: string;
73
+ }): Promise<NativeSafetyExecutionDecision>;
51
74
  /** Classify a synthetic removal through this exact supervisor without executing
52
75
  * the returned command or creating a file. A harmless ':' would not test whether
53
76
  * the saved PATH can actually resolve Trash. */
@@ -56,3 +79,10 @@ export declare function verifyNativeSafetyRegistration(home?: string): Promise<{
56
79
  path: string;
57
80
  sha256: string;
58
81
  }>;
82
+ /** Read-only compatibility check for native dispatch. This classifies one
83
+ * synthetic removal and requires the execution capability; it never executes
84
+ * the rewrite, creates a fixture, authenticates Trash or proves harness adoption. */
85
+ export declare function verifyNativeSafetyExecutionRegistration(home?: string): Promise<{
86
+ path: string;
87
+ sha256: string;
88
+ }>;
@@ -148,6 +148,7 @@ class NativeSafetyError extends Error {
148
148
  this.name = "NativeSafetyError";
149
149
  }
150
150
  }
151
+ var executionSchema = "hasna.hooks.execution.v1";
151
152
  var hash = (value) => createHash2("sha256").update(value).digest("hex");
152
153
  var filename = "sumi-trash-guard.json";
153
154
  var owner = (uid) => uid === 0 || uid === process.getuid?.();
@@ -305,10 +306,31 @@ function registerNativeSafety(options = {}) {
305
306
  async function evaluateNativeSafety(event, options = {}) {
306
307
  return evaluateRecord(event, readNativeSafetyRegistration(options.home).record, options.signal);
307
308
  }
308
- async function evaluateRecord(event, record, signal) {
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) {
309
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))
310
332
  throw new NativeSafetyError("input_invalid");
311
- const input = JSON.stringify(event);
333
+ const input = JSON.stringify(executionAgent === undefined ? event : { ...event, hasna_execution: { schema: executionSchema, agent: executionAgent } });
312
334
  if (Buffer.byteLength(input) > 1048576)
313
335
  throw new NativeSafetyError("input_invalid");
314
336
  const output = await new Promise((accept, reject) => {
@@ -333,7 +355,7 @@ async function evaluateRecord(event, record, signal) {
333
355
  throw new Error;
334
356
  if (verdict.permissionDecision === "deny" && Object.keys(verdict).sort().join() === "hookEventName,permissionDecision,permissionDecisionReason" && typeof verdict.permissionDecisionReason === "string" && verdict.permissionDecisionReason.trim())
335
357
  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())
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))
337
359
  return { decision: "rewrite", command: verdict.updatedInput.command };
338
360
  } catch {}
339
361
  throw new NativeSafetyError("guard_failed");
@@ -353,11 +375,27 @@ async function verifyNativeSafetyRegistration(home = homedir2()) {
353
375
  throw new NativeSafetyError("binding_changed");
354
376
  return { path: before.path, sha256: before.sha256 };
355
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
+ }
356
392
  export {
357
393
  verifyNativeSafetyRegistration,
394
+ verifyNativeSafetyExecutionRegistration,
358
395
  verifyNativeSafetyCommand,
359
396
  registerNativeSafety,
360
397
  readNativeSafetyRegistration,
398
+ evaluateNativeSafetyForExecution,
361
399
  evaluateNativeSafety,
362
400
  NativeSafetyError
363
401
  };
@@ -886,8 +886,11 @@ function shellQuoteWord(word: string): string {
886
886
  * Replace the verb token of every owned `rm` with `<quoted trash> guard`,
887
887
  * leaving every other byte of the command untouched.
888
888
  */
889
- export function rewriteCommand(command: string, hits: RmHit[], trashPath: string): string {
890
- 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`;
891
894
  const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
892
895
  let out = command;
893
896
  for (const hit of ordered) {
@@ -956,6 +959,24 @@ export interface GuardDependencies {
956
959
  cwd: string;
957
960
  findTrash: (env?: NodeJS.ProcessEnv) => string | null;
958
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
+ });
959
980
  }
960
981
 
961
982
  function deny(reason: string): CodewithHookOutput {
@@ -1078,8 +1099,12 @@ export function evaluate(input: CodewithHookInput, deps: GuardDependencies): Cod
1078
1099
  }
1079
1100
  const trashPath = trash.path;
1080
1101
 
1081
- 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);
1082
1106
  const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
1107
+ if (deps.execution) updatedInput.hasna_execution = "hasna.hooks.execution.v1";
1083
1108
 
1084
1109
  if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
1085
1110
  // Never allow a partial rewrite: the harness falls back to the ORIGINAL
@@ -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, inspectTrash: inspectTrashBinary }) : { 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.4",
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": {