@hasna/hooks 0.10.4 → 0.10.6

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
@@ -92,6 +92,36 @@ Repair unsafe installation permissions with a verified package installer, then
92
92
  check again. This check alone does not prove hosted authentication, native hook
93
93
  trust or a running agent's interception.
94
94
 
95
+ ## Codex safety readiness
96
+
97
+ ```bash
98
+ hooks safety verify --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
99
+ ```
100
+
101
+ This bounded, read-only check starts its own fresh Codex app-server and asks the
102
+ native consumer for the selected project's hook definitions and feature state.
103
+ It requires exactly one enabled, trusted, synchronous bundled Trash guard, checks
104
+ its installed supervisor by classifying data, and verifies that the native
105
+ definition stayed unchanged. It returns compact JSON and a nonzero exit on
106
+ missing, disabled, untrusted, changed or unsafe state. No deletion is executed.
107
+
108
+ The command accepts a native Linux ELF or macOS Mach-O executable for the host
109
+ architecture (arm64 or x64); script launchers are refused before execution. When
110
+ Codex is installed through npm, pass the platform package's native binary rather
111
+ than its JavaScript launcher. Executable ownership, permissions, ancestry and
112
+ bytes are checked. Supported protocol versions are 0.153.0, 0.154.0, 0.155.0,
113
+ 0.155.1 and 0.156.1; other versions refuse pending compatibility validation.
114
+ The version probe has a five-second forced timeout and native discovery a
115
+ fifteen-second lifetime budget. Neither step restarts an existing agent.
116
+
117
+ Success reports `nativeDiscoveryVerified: true`, `guardVerified: true` and
118
+ `nativeAdoptionVerified: false`. It does not enroll trust, change configuration,
119
+ authenticate Trash, verify MCP or Instructions, or prove interception by an
120
+ already-running session. Complete those separate setup checks and a fresh native
121
+ interception proof before declaring station support. Use this small readiness
122
+ check after installation, meaningful upgrades and during fresh-agent startup;
123
+ never substitute repeated deletion smoke tests for a startup check.
124
+
95
125
  ## Optional Mementos prompt context
96
126
 
97
127
  `hooks install mementos-context --target codex` registers a native prompt hook.
@@ -333,13 +363,27 @@ Trash script's Bun interpreter and all preceding PATH directories are checked
333
363
  before its identity probe runs; unsafe or unsupported interpreter resolution
334
364
  reports `unsafe_interpreter` without executing it.
335
365
 
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.
366
+ Native adapters import `@hasna/hooks/native-safety` and pass the final shell/patch
367
+ operation to `evaluateNativeSafetyForExecution`, with cancellation and an explicit
368
+ agent label. A rewrite returns the complete command, shell, resolved cwd and
369
+ environment. The adapter must use those fields together, disable environment
370
+ inheritance, and prevent later hooks from changing them. Every rewritten removal
371
+ uses the verified absolute Bun runtime with a clean environment and project
372
+ configuration, env files, macros and installation disabled. Deletions combined
373
+ with shell environment changes are refused; run those removals separately.
374
+
375
+ Use `hooks safety verify --target sumi --execution` for a small, read-only check
376
+ that the saved worker supports this execution contract. A legacy worker that only
377
+ classifies commands cannot pass it. The compatible `evaluateNativeSafety` API and
378
+ verification without `--execution` classify commands only; their success does not
379
+ prove execution compatibility. Neither check runs the returned deletion, creates
380
+ a test file, authenticates Trash or proves native adoption.
381
+
382
+ The bounded agent label can identify the Sumi session in Trash's existing name
383
+ field; it does not invent unsupported harness/session fields. Missing or unhealthy
384
+ bindings reject the call. This covers supported shell removal and whole-file patch
385
+ deletion, not arbitrary filesystem writes or plugin executor replacements.
386
+ Installing the record alone does not activate a harness.
343
387
 
344
388
  ## Data Directory
345
389
 
package/bin/index.js CHANGED
@@ -37388,7 +37388,7 @@ var init_redact = __esm(() => {
37388
37388
  });
37389
37389
 
37390
37390
  // src/lib/profiles.ts
37391
- import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as readFileSync14, writeFileSync as writeFileSync10, readdirSync as readdirSync8, rmSync, cpSync as cpSync2 } from "fs";
37391
+ import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as readFileSync15, writeFileSync as writeFileSync10, readdirSync as readdirSync8, rmSync, cpSync as cpSync2 } from "fs";
37392
37392
  import { join as join19 } from "path";
37393
37393
  function getProfilesDir() {
37394
37394
  const newDir = join19(getEffectiveDataRoot(), "profiles");
@@ -37434,7 +37434,7 @@ function getProfile(id) {
37434
37434
  try {
37435
37435
  if (!existsSync15(path))
37436
37436
  return null;
37437
- return JSON.parse(readFileSync14(path, "utf-8"));
37437
+ return JSON.parse(readFileSync15(path, "utf-8"));
37438
37438
  } catch {
37439
37439
  return null;
37440
37440
  }
@@ -37448,7 +37448,7 @@ function listProfiles() {
37448
37448
  const profiles = [];
37449
37449
  for (const file2 of files) {
37450
37450
  try {
37451
- const content = readFileSync14(join19(dir, file2), "utf-8");
37451
+ const content = readFileSync15(join19(dir, file2), "utf-8");
37452
37452
  profiles.push(JSON.parse(content));
37453
37453
  } catch {}
37454
37454
  }
@@ -37498,12 +37498,12 @@ import { spawnSync as spawnSync2 } from "child_process";
37498
37498
  import { closeSync as closeSync22, fstatSync as fstatSync22, openSync as openSync22, readFileSync as readFileSync22 } from "fs";
37499
37499
  import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
37500
37500
  import { hostname as osHostname } from "os";
37501
- import { isAbsolute as isAbsolute17, join as join22 } from "path";
37501
+ import { isAbsolute as isAbsolute18, join as join22 } from "path";
37502
37502
  import { createRequire as createRequire3 } from "module";
37503
37503
  import { fileURLToPath as fileURLToPath22 } from "url";
37504
- import { closeSync as closeSync6, fstatSync as fstatSync5, lstatSync as lstatSync10, openSync as openSync6, readFileSync as readFileSync15, realpathSync as realpathSync9, statSync as statSync2 } from "fs";
37504
+ import { closeSync as closeSync7, fstatSync as fstatSync6, lstatSync as lstatSync15, openSync as openSync7, readFileSync as readFileSync17, realpathSync as realpathSync10, statSync as statSync2 } from "fs";
37505
37505
  import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
37506
- import { basename as basename5, dirname as dirname19, join as join20, relative, sep as sep3 } from "path";
37506
+ import { basename as basename5, dirname as dirname20, join as join20, relative, sep as sep3 } from "path";
37507
37507
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
37508
37508
  function envToken2(name) {
37509
37509
  return name.toUpperCase().replace(/-/g, "_");
@@ -37577,16 +37577,16 @@ function o(e2, r2, t2) {
37577
37577
  }
37578
37578
  }
37579
37579
  function entryPoint(directory2) {
37580
- const root = realpathSync9(directory2);
37581
- const fd = openSync6(join20(root, "package.json"), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
37580
+ const root = realpathSync10(directory2);
37581
+ const fd = openSync7(join20(root, "package.json"), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
37582
37582
  let pkg;
37583
37583
  try {
37584
- const stat2 = fstatSync5(fd);
37584
+ const stat2 = fstatSync6(fd);
37585
37585
  if (!stat2.isFile() || stat2.size > MAX_PACKAGE_BYTES3)
37586
37586
  throw new Error("Invalid Secrets package metadata");
37587
- pkg = JSON.parse(readFileSync15(fd, "utf8"));
37587
+ pkg = JSON.parse(readFileSync17(fd, "utf8"));
37588
37588
  } finally {
37589
- closeSync6(fd);
37589
+ closeSync7(fd);
37590
37590
  }
37591
37591
  if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== PACKAGE) {
37592
37592
  throw new Error("Invalid Secrets package identity");
@@ -37598,7 +37598,7 @@ function entryPoint(directory2) {
37598
37598
  if (/[\\%?#\0]/.test(target) || parts.some((part) => !part || part === "." || part === ".." || part === "node_modules")) {
37599
37599
  throw new Error("Invalid Secrets import export");
37600
37600
  }
37601
- const file2 = realpathSync9(join20(root, ...parts));
37601
+ const file2 = realpathSync10(join20(root, ...parts));
37602
37602
  const within = relative(root, file2);
37603
37603
  if (!within || within === ".." || within.startsWith(`..${sep3}`) || !statSync2(file2).isFile()) {
37604
37604
  throw new Error("Secrets import export escapes its package or is not a file");
@@ -37606,13 +37606,13 @@ function entryPoint(directory2) {
37606
37606
  return pathToFileURL(file2).href;
37607
37607
  }
37608
37608
  function resolveInstalledSecrets(parentUrl) {
37609
- let directory2 = dirname19(fileURLToPath3(parentUrl));
37609
+ let directory2 = dirname20(fileURLToPath3(parentUrl));
37610
37610
  for (;; ) {
37611
37611
  if (basename5(directory2) !== "node_modules") {
37612
37612
  const candidate = join20(directory2, "node_modules", PACKAGE);
37613
37613
  let absent = false;
37614
37614
  try {
37615
- lstatSync10(candidate);
37615
+ lstatSync15(candidate);
37616
37616
  } catch (error61) {
37617
37617
  if (error61.code !== "ENOENT")
37618
37618
  throw error61;
@@ -37621,7 +37621,7 @@ function resolveInstalledSecrets(parentUrl) {
37621
37621
  if (!absent)
37622
37622
  return entryPoint(candidate);
37623
37623
  }
37624
- const parent = dirname19(directory2);
37624
+ const parent = dirname20(directory2);
37625
37625
  if (parent === directory2)
37626
37626
  throw new Error("Secrets SDK is not installed for this consumer");
37627
37627
  directory2 = parent;
@@ -37633,7 +37633,7 @@ function homeDir(env) {
37633
37633
  }
37634
37634
  function absoluteOverride(env, key) {
37635
37635
  const value = env[key]?.trim();
37636
- return value && isAbsolute17(value) ? value : null;
37636
+ return value && isAbsolute18(value) ? value : null;
37637
37637
  }
37638
37638
  function hasnaHomeDir(env) {
37639
37639
  const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
@@ -38740,8 +38740,8 @@ var init_db_writer = __esm(() => {
38740
38740
  });
38741
38741
 
38742
38742
  // src/lib/hook-env.ts
38743
- import { dirname as dirname20, isAbsolute as isAbsolute18 } from "path";
38744
- import { existsSync as existsSync16, realpathSync as realpathSync10, statSync as statSync4 } from "fs";
38743
+ import { dirname as dirname21, isAbsolute as isAbsolute19 } from "path";
38744
+ import { existsSync as existsSync16, realpathSync as realpathSync11, statSync as statSync4 } from "fs";
38745
38745
  import { tmpdir } from "os";
38746
38746
  function isDeniedEnvName(name) {
38747
38747
  const upper = name.toUpperCase();
@@ -38760,7 +38760,7 @@ function isBashFunctionEnvName(name) {
38760
38760
  return name.startsWith("BASH_FUNC_");
38761
38761
  }
38762
38762
  function isUnsafePathEntry(entry, home) {
38763
- if (entry === "" || !isAbsolute18(entry))
38763
+ if (entry === "" || !isAbsolute19(entry))
38764
38764
  return true;
38765
38765
  if (home && (entry === home || entry.startsWith(home + "/")))
38766
38766
  return true;
@@ -38770,7 +38770,7 @@ function isUnsafePathEntry(entry, home) {
38770
38770
  return true;
38771
38771
  const canonical4 = (path) => {
38772
38772
  try {
38773
- return realpathSync10(path);
38773
+ return realpathSync11(path);
38774
38774
  } catch {
38775
38775
  return path;
38776
38776
  }
@@ -38791,7 +38791,7 @@ function sanitizeHookPath(pathValue, home) {
38791
38791
  const baseline = PATH_BASELINE.slice();
38792
38792
  if (process.platform === "darwin" && existsSync16("/opt/homebrew/bin"))
38793
38793
  baseline.push("/opt/homebrew/bin");
38794
- const bunDir = dirname20(process.execPath);
38794
+ const bunDir = dirname21(process.execPath);
38795
38795
  const seen = new Set;
38796
38796
  const out = [];
38797
38797
  for (const dir of baseline) {
@@ -38916,7 +38916,7 @@ __export(exports_run, {
38916
38916
  });
38917
38917
  import { randomBytes as randomBytes2 } from "crypto";
38918
38918
  import { rmSync as rmSync2, writeFileSync as writeFileSync11 } from "fs";
38919
- import { basename as basename9, dirname as dirname21, isAbsolute as isAbsolute19, join as join24 } from "path";
38919
+ import { basename as basename9, dirname as dirname22, isAbsolute as isAbsolute20, join as join24 } from "path";
38920
38920
  function extensionOf(scriptPath) {
38921
38921
  const base = basename9(scriptPath);
38922
38922
  const dot = base.lastIndexOf(".");
@@ -38985,7 +38985,7 @@ async function readPipeWithDeadline(stream, deadlineMs) {
38985
38985
  return chunks.join("");
38986
38986
  }
38987
38987
  async function executeVerifiedScript(options) {
38988
- const scriptDir = dirname21(options.scriptPath);
38988
+ const scriptDir = dirname22(options.scriptPath);
38989
38989
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
38990
38990
  const tempPath = join24(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
38991
38991
  let proc = null;
@@ -38996,7 +38996,7 @@ async function executeVerifiedScript(options) {
38996
38996
  }, options.timeout) : null;
38997
38997
  try {
38998
38998
  writeFileSync11(tempPath, options.content, { flag: "wx", mode: 384 });
38999
- proc = Bun.spawn([...interpreter.command, isAbsolute19(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
38999
+ proc = Bun.spawn([...interpreter.command, isAbsolute20(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
39000
39000
  stdin: new Response(options.stdin),
39001
39001
  stdout: "pipe",
39002
39002
  stderr: "pipe",
@@ -39078,7 +39078,7 @@ __export(exports_custom_install, {
39078
39078
  installCustomSource: () => installCustomSource,
39079
39079
  ensureCustomDir: () => ensureCustomDir
39080
39080
  });
39081
- import { existsSync as existsSync17, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as readFileSync17, rmSync as rmSync4 } from "fs";
39081
+ import { existsSync as existsSync17, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as readFileSync19, rmSync as rmSync4 } from "fs";
39082
39082
  import { join as join27 } from "path";
39083
39083
  import { tmpdir as tmpdir2 } from "os";
39084
39084
  function manifestAt(dir) {
@@ -39086,7 +39086,7 @@ function manifestAt(dir) {
39086
39086
  if (!existsSync17(path))
39087
39087
  return;
39088
39088
  try {
39089
- return parseManifest(readFileSync17(path, "utf-8"));
39089
+ return parseManifest(readFileSync19(path, "utf-8"));
39090
39090
  } catch {
39091
39091
  return;
39092
39092
  }
@@ -39147,7 +39147,7 @@ async function installCustomSource(source) {
39147
39147
  throw new Error(`No such local path: ${source}`);
39148
39148
  }
39149
39149
  if (source.endsWith("manifest.json")) {
39150
- const manifest = parseManifest(readFileSync17(source, "utf-8"));
39150
+ const manifest = parseManifest(readFileSync19(source, "utf-8"));
39151
39151
  return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
39152
39152
  }
39153
39153
  return copyManifestDir(source, source, "local");
@@ -39196,13 +39196,13 @@ __export(exports_sync, {
39196
39196
  fetchPinnedHook: () => fetchPinnedHook,
39197
39197
  commitSyncArtifacts: () => commitSyncArtifacts
39198
39198
  });
39199
- import { createHash as createHash12 } from "crypto";
39200
- import { readFileSync as readFileSync19 } from "fs";
39199
+ import { createHash as createHash17 } from "crypto";
39200
+ import { readFileSync as readFileSync20 } from "fs";
39201
39201
  function sha256OfText(text3) {
39202
- return createHash12("sha256").update(text3).digest("hex");
39202
+ return createHash17("sha256").update(text3).digest("hex");
39203
39203
  }
39204
39204
  function sha256OfPath(path) {
39205
- return createHash12("sha256").update(readFileSync19(path)).digest("hex");
39205
+ return createHash17("sha256").update(readFileSync20(path)).digest("hex");
39206
39206
  }
39207
39207
  function collectBundledCatalog() {
39208
39208
  const out = [];
@@ -40357,13 +40357,13 @@ __export(exports_serve, {
40357
40357
  SERVE_HOST: () => SERVE_HOST,
40358
40358
  DEFAULT_SERVE_PORT: () => DEFAULT_SERVE_PORT
40359
40359
  });
40360
- import { readFileSync as readFileSync20 } from "fs";
40361
- import { dirname as dirname22, join as join29 } from "path";
40360
+ import { readFileSync as readFileSync21 } from "fs";
40361
+ import { dirname as dirname23, join as join29 } from "path";
40362
40362
  import { fileURLToPath as fileURLToPath4 } from "url";
40363
40363
  function packageVersion() {
40364
40364
  try {
40365
- const pkgPath = join29(dirname22(fileURLToPath4(import.meta.url)), "..", "package.json");
40366
- return JSON.parse(readFileSync20(pkgPath, "utf-8")).version || "0.0.0";
40365
+ const pkgPath = join29(dirname23(fileURLToPath4(import.meta.url)), "..", "package.json");
40366
+ return JSON.parse(readFileSync21(pkgPath, "utf-8")).version || "0.0.0";
40367
40367
  } catch {
40368
40368
  return "0.0.0";
40369
40369
  }
@@ -40663,8 +40663,8 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
40663
40663
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
40664
40664
  import { z as z3 } from "zod";
40665
40665
  import { createServer } from "http";
40666
- import { existsSync as existsSync18, readFileSync as readFileSync21 } from "fs";
40667
- import { join as join30, dirname as dirname23 } from "path";
40666
+ import { existsSync as existsSync18, readFileSync as readFileSync23 } from "fs";
40667
+ import { join as join30, dirname as dirname24 } from "path";
40668
40668
  import { fileURLToPath as fileURLToPath5 } from "url";
40669
40669
  async function runVerifiedHook(name, stdin, timeoutMs) {
40670
40670
  const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
@@ -40673,7 +40673,7 @@ async function runVerifiedHook(name, stdin, timeoutMs) {
40673
40673
  if (!resolved || !hookScript || !existsSync18(hookScript)) {
40674
40674
  throw new Error(`Hook '${name}' not found`);
40675
40675
  }
40676
- const content = readFileSync21(hookScript);
40676
+ const content = readFileSync23(hookScript);
40677
40677
  const sha4 = sha256Of(content);
40678
40678
  const check2 = checkScriptHash(name, sha4);
40679
40679
  if (!check2.ok) {
@@ -40905,7 +40905,7 @@ function createHooksServer() {
40905
40905
  }
40906
40906
  if (meta3 && settingsExist) {
40907
40907
  try {
40908
- const settings = JSON.parse(readFileSync21(settingsPath, "utf-8"));
40908
+ const settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
40909
40909
  if (!hookRegisteredInSettings(settings, name, meta3.event, meta3.matcher)) {
40910
40910
  const eventName = meta3.event.split(":")[0];
40911
40911
  issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
@@ -40945,7 +40945,7 @@ function createHooksServer() {
40945
40945
  const readmePath = join30(hookPath, "README.md");
40946
40946
  let readme = "";
40947
40947
  if (existsSync18(readmePath)) {
40948
- readme = readFileSync21(readmePath, "utf-8");
40948
+ readme = readFileSync23(readmePath, "utf-8");
40949
40949
  }
40950
40950
  if (verbose) {
40951
40951
  return { content: [{ type: "text", text: JSON.stringify({ ...meta3, readme }) }] };
@@ -41275,7 +41275,7 @@ function createHooksServer() {
41275
41275
  let settings = {};
41276
41276
  try {
41277
41277
  if (existsSync18(settingsPath))
41278
- settings = JSON.parse(readFileSync21(settingsPath, "utf-8"));
41278
+ settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
41279
41279
  } catch {}
41280
41280
  if (!settings.hooks)
41281
41281
  settings.hooks = {};
@@ -41298,7 +41298,7 @@ function createHooksServer() {
41298
41298
  let settings = {};
41299
41299
  try {
41300
41300
  if (existsSync18(settingsPath))
41301
- settings = JSON.parse(readFileSync21(settingsPath, "utf-8"));
41301
+ settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
41302
41302
  } catch {}
41303
41303
  if (settings.hooks?.__disabled) {
41304
41304
  settings.hooks.__disabled = settings.hooks.__disabled.filter((n2) => n2 !== name);
@@ -41668,13 +41668,13 @@ var init_server = __esm(() => {
41668
41668
  init_redact();
41669
41669
  init_authority();
41670
41670
  init_storage();
41671
- __dirname3 = dirname23(fileURLToPath5(import.meta.url));
41671
+ __dirname3 = dirname24(fileURLToPath5(import.meta.url));
41672
41672
  pkg = { name: "@hasna/hooks", version: "0.0.0" };
41673
41673
  try {
41674
41674
  for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
41675
41675
  const p = join30(__dirname3, rel);
41676
41676
  if (existsSync18(p)) {
41677
- pkg = JSON.parse(readFileSync21(p, "utf-8"));
41677
+ pkg = JSON.parse(readFileSync23(p, "utf-8"));
41678
41678
  break;
41679
41679
  }
41680
41680
  }
@@ -43416,8 +43416,8 @@ var {
43416
43416
 
43417
43417
  // src/cli/index.tsx
43418
43418
  import chalk2 from "chalk";
43419
- import { existsSync as existsSync19, readFileSync as readFileSync23, rmSync as rmSync6 } from "fs";
43420
- import { join as join31, dirname as dirname24 } from "path";
43419
+ import { existsSync as existsSync19, readFileSync as readFileSync25, rmSync as rmSync6 } from "fs";
43420
+ import { join as join31, dirname as dirname25 } from "path";
43421
43421
  import { fileURLToPath as fileURLToPath6 } from "url";
43422
43422
 
43423
43423
  // src/cli/components/App.tsx
@@ -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";
@@ -44987,6 +45023,268 @@ function inspectTrashBinary(env = process.env) {
44987
45023
  return { ok: false, code: failure };
44988
45024
  }
44989
45025
 
45026
+ // src/lib/codex-safety-check.ts
45027
+ init_native_safety();
45028
+ import { spawn as spawn2, execFile as execFile2 } from "child_process";
45029
+ import { createHash as createHash12 } from "crypto";
45030
+ import { constants as constants12, openSync as openSync6, closeSync as closeSync6, fstatSync as fstatSync5, lstatSync as lstatSync10, readFileSync as readFileSync14, realpathSync as realpathSync9 } from "fs";
45031
+ import { homedir as homedir8 } from "os";
45032
+ import { dirname as dirname19, isAbsolute as isAbsolute17 } from "path";
45033
+ import { promisify } from "util";
45034
+ var versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1"]);
45035
+ var matcher = "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$";
45036
+
45037
+ class CodexSafetyError extends Error {
45038
+ code;
45039
+ constructor(code2) {
45040
+ super(`Codex deletion safety check failed (${code2}).`);
45041
+ this.code = code2;
45042
+ this.name = "CodexSafetyError";
45043
+ }
45044
+ }
45045
+ function need5(condition, code2) {
45046
+ if (!condition)
45047
+ throw new CodexSafetyError(code2);
45048
+ }
45049
+ var digest3 = (bytes) => createHash12("sha256").update(bytes).digest("hex");
45050
+ function nativeHeader(bytes) {
45051
+ if (bytes.length < 64 || !["arm64", "x64"].includes(process.arch))
45052
+ return false;
45053
+ if (process.platform === "darwin") {
45054
+ const cpu = process.arch === "arm64" ? 16777228 : 16777223;
45055
+ return bytes.readUInt32LE(0) === 4277009103 && bytes.readUInt32LE(4) === cpu && bytes.readUInt32LE(12) === 2 && bytes.readUInt32LE(16) > 0 && bytes.readUInt32LE(16) <= 4096 && bytes.readUInt32LE(20) > 0 && bytes.readUInt32LE(20) <= bytes.length - 32;
45056
+ }
45057
+ if (process.platform === "linux") {
45058
+ const offset = bytes.readBigUInt64LE(32), count = bytes.readUInt16LE(56);
45059
+ return bytes.subarray(0, 7).equals(Buffer.from([127, 69, 76, 70, 2, 1, 1])) && [2, 3].includes(bytes.readUInt16LE(16)) && bytes.readUInt16LE(18) === (process.arch === "arm64" ? 183 : 62) && bytes.readUInt32LE(20) === 1 && bytes.readUInt16LE(52) === 64 && bytes.readUInt16LE(54) === 56 && count > 0 && count <= 1024 && offset >= 64n && offset + BigInt(count * 56) <= BigInt(bytes.length);
45060
+ }
45061
+ return false;
45062
+ }
45063
+ function executable(command) {
45064
+ const found = isAbsolute17(command) ? command : Bun.which(command);
45065
+ need5(found, "native_unavailable");
45066
+ try {
45067
+ const path = realpathSync9(found);
45068
+ for (let parent = dirname19(path);; parent = dirname19(parent)) {
45069
+ const stat2 = lstatSync10(parent);
45070
+ need5(stat2.isDirectory() && [0, process.getuid?.()].includes(stat2.uid) && !(stat2.mode & 18), "native_unsafe_executable");
45071
+ if (parent === dirname19(parent))
45072
+ break;
45073
+ }
45074
+ const fd = openSync6(path, constants12.O_RDONLY | constants12.O_NOFOLLOW | constants12.O_NONBLOCK);
45075
+ try {
45076
+ const stat2 = fstatSync5(fd);
45077
+ need5(stat2.isFile() && [0, process.getuid?.()].includes(stat2.uid) && !(stat2.mode & 18) && !!(stat2.mode & 64) && stat2.size <= 256000000, "native_unsafe_executable");
45078
+ const bytes = readFileSync14(fd);
45079
+ need5(bytes.length <= 256000000, "native_unavailable");
45080
+ need5(nativeHeader(bytes), "native_executable_required");
45081
+ return { path, sha256: digest3(bytes) };
45082
+ } finally {
45083
+ closeSync6(fd);
45084
+ }
45085
+ } catch (error61) {
45086
+ if (error61 instanceof CodexSafetyError)
45087
+ throw error61;
45088
+ throw new CodexSafetyError("native_unavailable");
45089
+ }
45090
+ }
45091
+ async function connectCodexSafety(options) {
45092
+ const command = options.command ?? "codex", before = executable(command);
45093
+ const env = { ...process.env, HOME: options.home, NO_COLOR: "1" };
45094
+ for (const key of ["NODE_OPTIONS", "NODE_PATH", "BUN_OPTIONS", "BASH_ENV", "ENV", "DYLD_INSERT_LIBRARIES", "LD_PRELOAD"])
45095
+ delete env[key];
45096
+ if (options.codexHome === undefined)
45097
+ delete env.CODEX_HOME;
45098
+ else
45099
+ env.CODEX_HOME = options.codexHome;
45100
+ let version2;
45101
+ try {
45102
+ version2 = (await promisify(execFile2)(before.path, ["--version"], { env, cwd: options.home, timeout: 5000, killSignal: "SIGKILL", maxBuffer: 4096 })).stdout.trim();
45103
+ } catch {
45104
+ throw new CodexSafetyError("native_unavailable");
45105
+ }
45106
+ need5(versions.has(version2), "native_unsupported");
45107
+ need5(JSON.stringify(executable(command)) === JSON.stringify(before), "guard_changed");
45108
+ const child = spawn2(before.path, ["app-server", "--strict-config", "--stdio"], { env, cwd: options.home, stdio: ["pipe", "pipe", "pipe"] });
45109
+ let stopped = false, buffer = Buffer.alloc(0), total = 0, nextId = 1;
45110
+ const pending = new Map;
45111
+ const fail2 = () => {
45112
+ stopped = true;
45113
+ for (const waiter of pending.values())
45114
+ waiter.reject(new CodexSafetyError("native_failed"));
45115
+ pending.clear();
45116
+ };
45117
+ const deadline = setTimeout(() => {
45118
+ fail2();
45119
+ child.kill("SIGKILL");
45120
+ }, 15000);
45121
+ const exited = new Promise((done) => {
45122
+ child.once("exit", () => {
45123
+ fail2();
45124
+ clearTimeout(deadline);
45125
+ done();
45126
+ });
45127
+ child.once("error", () => {
45128
+ fail2();
45129
+ clearTimeout(deadline);
45130
+ done();
45131
+ });
45132
+ });
45133
+ child.stdin.on("error", fail2);
45134
+ const account = (length) => {
45135
+ total += length;
45136
+ if (total > 8388608) {
45137
+ fail2();
45138
+ child.kill("SIGKILL");
45139
+ return false;
45140
+ }
45141
+ return true;
45142
+ };
45143
+ child.stderr.on("data", (bytes) => {
45144
+ account(bytes.length);
45145
+ });
45146
+ child.stdout.on("data", (bytes) => {
45147
+ if (stopped || !account(bytes.length))
45148
+ return;
45149
+ buffer = Buffer.concat([buffer, bytes]);
45150
+ for (;; ) {
45151
+ const end = buffer.indexOf(10);
45152
+ if (end < 0)
45153
+ return;
45154
+ const line = buffer.subarray(0, end);
45155
+ buffer = buffer.subarray(end + 1);
45156
+ try {
45157
+ const value = JSON.parse(line.toString("utf8"));
45158
+ if (!value || typeof value !== "object" || Array.isArray(value))
45159
+ throw Error();
45160
+ if (!Object.hasOwn(value, "id"))
45161
+ continue;
45162
+ const waiter = pending.get(value.id);
45163
+ if (!waiter)
45164
+ throw Error();
45165
+ pending.delete(value.id);
45166
+ if (value.error || !Object.hasOwn(value, "result"))
45167
+ waiter.reject(new CodexSafetyError("native_failed"));
45168
+ else
45169
+ waiter.resolve(value.result);
45170
+ } catch {
45171
+ fail2();
45172
+ child.kill("SIGKILL");
45173
+ return;
45174
+ }
45175
+ }
45176
+ });
45177
+ const request = (method, params) => new Promise((accept, reject) => {
45178
+ if (stopped) {
45179
+ reject(new CodexSafetyError("native_failed"));
45180
+ return;
45181
+ }
45182
+ const id = nextId++;
45183
+ pending.set(id, { resolve: accept, reject });
45184
+ child.stdin.write(JSON.stringify({ id, method, params }) + `
45185
+ `);
45186
+ });
45187
+ const rpc = {
45188
+ version: version2,
45189
+ request,
45190
+ async close() {
45191
+ fail2();
45192
+ child.stdin.end();
45193
+ const force = setTimeout(() => child.kill("SIGKILL"), 1000);
45194
+ await exited;
45195
+ clearTimeout(force);
45196
+ clearTimeout(deadline);
45197
+ need5(JSON.stringify(executable(command)) === JSON.stringify(before), "guard_changed");
45198
+ }
45199
+ };
45200
+ try {
45201
+ await request("initialize", { clientInfo: { name: "hooks-safety-check", version: "1" }, capabilities: { experimentalApi: true } });
45202
+ child.stdin.write(`{"method":"initialized"}
45203
+ `);
45204
+ return rpc;
45205
+ } catch {
45206
+ await rpc.close();
45207
+ throw new CodexSafetyError("native_failed");
45208
+ }
45209
+ }
45210
+ function selectCodexSafety(discovery, configuration, home, cwd, featureEnabled) {
45211
+ const data = discovery?.data;
45212
+ need5(Array.isArray(data) && data.length === 1 && data[0]?.cwd === cwd && Array.isArray(data[0]?.hooks) && data[0].hooks.length <= 1e4 && Array.isArray(data[0].errors) && data[0].errors.length === 0 && Array.isArray(data[0].warnings) && data[0].warnings.length === 0, "native_discovery_failed");
45213
+ const config2 = configuration?.config;
45214
+ need5(config2 && typeof config2 === "object" && !Array.isArray(config2), "native_discovery_failed");
45215
+ need5((config2.features?.hooks ?? featureEnabled) === true, "hooks_disabled");
45216
+ for (const hook2 of data[0].hooks)
45217
+ if (typeof hook2?.command === "string") {
45218
+ if (/\bhasna-native-safety-v\d+\b/.test(hook2.command))
45219
+ need5(nativeSafetyCommandBinding(hook2.command), "guard_definition_invalid");
45220
+ need5(!/^(?:hooks run trash-guard|hook-trash-guard)(?:\s|$)/.test(hook2.command.trim()), "guard_definition_invalid");
45221
+ }
45222
+ const selected = data[0].hooks.filter((hook2) => typeof hook2?.command === "string" && nativeSafetyCommandBinding(hook2.command)?.name === "trash-guard");
45223
+ need5(selected.length !== 0, "guard_missing");
45224
+ need5(selected.length === 1, "guard_ambiguous");
45225
+ const hook = selected[0], binding = nativeSafetyCommandBinding(hook.command);
45226
+ need5(binding.home === home && hook.eventName === "preToolUse" && hook.handlerType === "command" && hook.matcher === matcher && hook.timeoutSec === 10 && hook.async === false && hook.statusMessage === null && hook.additionalContextLimit === null && typeof hook.key === "string" && hook.key.length <= 4096 && typeof hook.sourcePath === "string" && isAbsolute17(hook.sourcePath) && hook.pluginId === null && hook.isManaged === false && ["user", "project"].includes(hook.source) && typeof hook.currentHash === "string" && /^sha256:[a-f0-9]{64}$/.test(hook.currentHash), "guard_definition_invalid");
45227
+ need5(hook.enabled === true, "guard_disabled");
45228
+ need5(hook.trustStatus === "trusted", "guard_untrusted");
45229
+ return { key: hook.key, command: hook.command, currentHash: hook.currentHash, sourcePath: hook.sourcePath, enabled: true, trustStatus: "trusted" };
45230
+ }
45231
+ async function verifyCodexNativeSafety(options = {}, dependencies = {}) {
45232
+ const home = realpathSync9(options.home ?? homedir8()), cwd = realpathSync9(options.cwd ?? process.cwd());
45233
+ need5(isAbsolute17(home) && isAbsolute17(cwd), "native_unavailable");
45234
+ const rpc = await (dependencies.connect ?? connectCodexSafety)({ ...options, home });
45235
+ try {
45236
+ need5(versions.has(rpc.version), "native_unsupported");
45237
+ const inspect = async () => {
45238
+ const hooks = await rpc.request("hooks/list", { cwds: [cwd] });
45239
+ const configuration = await rpc.request("config/read", { cwd, includeLayers: false });
45240
+ let enabled;
45241
+ if (configuration?.config?.features?.hooks === undefined) {
45242
+ let cursor = null, count = 0;
45243
+ const seen = new Set;
45244
+ do {
45245
+ const page3 = await rpc.request("experimentalFeature/list", { cursor, limit: 100 });
45246
+ need5(Array.isArray(page3?.data) && page3.data.length <= 100 && (page3.nextCursor === null || typeof page3.nextCursor === "string"), "native_discovery_failed");
45247
+ count += page3.data.length;
45248
+ need5(count <= 1000, "native_discovery_failed");
45249
+ for (const feature of page3.data)
45250
+ if (feature?.name === "hooks") {
45251
+ need5(enabled === undefined && typeof feature.enabled === "boolean", "native_discovery_failed");
45252
+ enabled = feature.enabled;
45253
+ }
45254
+ cursor = page3.nextCursor;
45255
+ if (cursor !== null) {
45256
+ need5(cursor.length > 0 && cursor.length <= 4096 && !seen.has(cursor) && page3.data.length > 0, "native_discovery_failed");
45257
+ seen.add(cursor);
45258
+ }
45259
+ } while (cursor !== null);
45260
+ }
45261
+ return selectCodexSafety(hooks, configuration, home, cwd, enabled);
45262
+ };
45263
+ const before = await inspect();
45264
+ try {
45265
+ await (dependencies.verify ?? verifyNativeSafetyCommand)(before.command, home);
45266
+ } catch {
45267
+ throw new CodexSafetyError("guard_failed");
45268
+ }
45269
+ need5(JSON.stringify(await inspect()) === JSON.stringify(before), "guard_changed");
45270
+ return {
45271
+ ok: true,
45272
+ target: "codex",
45273
+ nativeVersion: rpc.version,
45274
+ cwd,
45275
+ key: before.key,
45276
+ currentHash: before.currentHash,
45277
+ enabled: true,
45278
+ trusted: true,
45279
+ guardVerified: true,
45280
+ nativeDiscoveryVerified: true,
45281
+ nativeAdoptionVerified: false
45282
+ };
45283
+ } finally {
45284
+ await rpc.close();
45285
+ }
45286
+ }
45287
+
44990
45288
  // src/cli/index.tsx
44991
45289
  init_registry();
44992
45290
  init_installer();
@@ -45003,9 +45301,9 @@ init_local_opt_in();
45003
45301
  init_transport2();
45004
45302
  init_db();
45005
45303
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
45006
- var __dirname4 = dirname24(fileURLToPath6(import.meta.url));
45304
+ var __dirname4 = dirname25(fileURLToPath6(import.meta.url));
45007
45305
  var pkgPath = existsSync19(join31(__dirname4, "..", "package.json")) ? join31(__dirname4, "..", "package.json") : join31(__dirname4, "..", "..", "package.json");
45008
- var pkg2 = JSON.parse(readFileSync23(pkgPath, "utf-8"));
45306
+ var pkg2 = JSON.parse(readFileSync25(pkgPath, "utf-8"));
45009
45307
  var program3 = new Command;
45010
45308
  function resolveScope(options) {
45011
45309
  if (options.project)
@@ -45085,7 +45383,7 @@ async function openLocalStoreOrExit(json3) {
45085
45383
  function readToken(tokenFile) {
45086
45384
  if (tokenFile) {
45087
45385
  try {
45088
- return readFileSync23(tokenFile, "utf-8").trim();
45386
+ return readFileSync25(tokenFile, "utf-8").trim();
45089
45387
  } catch {
45090
45388
  return;
45091
45389
  }
@@ -45099,9 +45397,9 @@ function readmePreview(readme, max = 280) {
45099
45397
  return preview2 ? truncateText2(preview2, max) : undefined;
45100
45398
  }
45101
45399
  function hookSummaryLine(hook, options = {}) {
45102
- const matcher = hook.matcher ? ` ${hook.matcher}` : "";
45400
+ const matcher2 = hook.matcher ? ` ${hook.matcher}` : "";
45103
45401
  const description = options.verbose ? ` - ${truncateText2(hook.description, 110)}` : "";
45104
- return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher}]`)} ${chalk2.dim(hook.category)}${description}`;
45402
+ return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher2}]`)} ${chalk2.dim(hook.category)}${description}`;
45105
45403
  }
45106
45404
  function listStoreHooks() {
45107
45405
  const out = [];
@@ -45251,7 +45549,7 @@ program3.command("run").argument("<hook>", "Hook to run").option("--profile <id>
45251
45549
  console.error(JSON.stringify({ error: `Hook script not found: ${hookScript ?? hook}` }));
45252
45550
  process.exit(1);
45253
45551
  }
45254
- const content = readFileSync23(hookScript);
45552
+ const content = readFileSync25(hookScript);
45255
45553
  const sha4 = sha256Of2(content);
45256
45554
  const check2 = checkScriptHash2(hook, sha4);
45257
45555
  if (!check2.ok) {
@@ -45853,7 +46151,7 @@ program3.command("doctor").option("-g, --global", "Check global settings", false
45853
46151
  let wiringCount = 0;
45854
46152
  if (settingsExist) {
45855
46153
  try {
45856
- const settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
46154
+ const settings = JSON.parse(readFileSync25(settingsPath, "utf-8"));
45857
46155
  wiringCount = countSettingsWiring(settings);
45858
46156
  } catch {}
45859
46157
  }
@@ -45883,7 +46181,7 @@ program3.command("doctor").option("-g, --global", "Check global settings", false
45883
46181
  }
45884
46182
  if (meta3 && settingsExist) {
45885
46183
  try {
45886
- const settings = JSON.parse(readFileSync23(settingsPath, "utf-8"));
46184
+ const settings = JSON.parse(readFileSync25(settingsPath, "utf-8"));
45887
46185
  if (!hookRegisteredInSettings(settings, name, meta3.event, meta3.matcher)) {
45888
46186
  const eventName = meta3.event.split(":")[0];
45889
46187
  issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
@@ -46089,7 +46387,7 @@ program3.command("docs").argument("[hook]", "Hook name (shows general docs if om
46089
46387
  const readmePath = join31(hookPath, "README.md");
46090
46388
  let readme = "";
46091
46389
  if (existsSync19(readmePath)) {
46092
- readme = readFileSync23(readmePath, "utf-8");
46390
+ readme = readFileSync25(readmePath, "utf-8");
46093
46391
  }
46094
46392
  if (options.json) {
46095
46393
  console.log(JSON.stringify({ ...meta3, readme }));
@@ -46759,12 +47057,25 @@ safety.command("install <guard>").option("--target <target>", "codex, claude or
46759
47057
  if (!result.success)
46760
47058
  process.exitCode = 1;
46761
47059
  });
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) => {
47060
+ safety.command("verify").requiredOption("--target <target>", "sumi or codex").option("--cwd <path>", "Codex project context to inspect (defaults to the current directory)").option("--codex-command <path>", "Installed Codex executable").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) => {
47061
+ if (options.target === "codex") {
47062
+ try {
47063
+ if (options.execution)
47064
+ throw new CodexSafetyError("native_unsupported");
47065
+ console.log(JSON.stringify(await verifyCodexNativeSafety({ command: options.codexCommand, cwd: options.cwd, codexHome: process.env.CODEX_HOME })));
47066
+ } catch (error61) {
47067
+ console.log(JSON.stringify({ ok: false, target: "codex", code: error61 instanceof CodexSafetyError ? error61.code : "native_failed" }));
47068
+ process.exitCode = 1;
47069
+ }
47070
+ return;
47071
+ }
46763
47072
  try {
47073
+ if (options.cwd || options.codexCommand)
47074
+ throw new NativeSafetyError("input_invalid");
46764
47075
  if (options.target !== "sumi")
46765
47076
  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 }));
47077
+ const current2 = await (options.execution ? verifyNativeSafetyExecutionRegistration() : verifyNativeSafetyRegistration());
47078
+ console.log(JSON.stringify({ ok: true, target: "sumi", sha256: current2.sha256, guardVerified: true, ...options.execution ? { executionCapabilityVerified: true } : {}, nativeAdoptionVerified: false }));
46768
47079
  } catch (error61) {
46769
47080
  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
47081
  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.6",
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": {