@hasna/hooks 0.9.0 → 0.9.1

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/dist/storage.js CHANGED
@@ -458,6 +458,18 @@ function runRetention(db, days) {
458
458
  import { Database } from "bun:sqlite";
459
459
  import { existsSync as existsSync3, mkdirSync, cpSync } from "fs";
460
460
  import { join as join3 } from "path";
461
+ function refuseLocalStore(message) {
462
+ localStoreRefusal = message;
463
+ }
464
+ function allowLocalStore() {
465
+ localStoreRefusal = null;
466
+ }
467
+ function isLocalStoreRefused() {
468
+ return localStoreRefusal !== null;
469
+ }
470
+ function localStoreRefusalMessage() {
471
+ return localStoreRefusal;
472
+ }
461
473
  function resolveDataDir() {
462
474
  const effective = getEffectiveDataRoot();
463
475
  const oldDir = join3(getHomeDir(), ".hooks");
@@ -481,6 +493,8 @@ function ensureDir(dbPath) {
481
493
  }
482
494
  }
483
495
  function getDb() {
496
+ if (localStoreRefusal !== null)
497
+ throw new Error(localStoreRefusal);
484
498
  if (instance)
485
499
  return instance;
486
500
  const dbPath = getDbPath();
@@ -506,7 +520,7 @@ function getDb() {
506
520
  }
507
521
  return instance;
508
522
  }
509
- var instance = null;
523
+ var instance = null, localStoreRefusal = null;
510
524
  var init_db = __esm(() => {
511
525
  init_app_home();
512
526
  init_migrations();
@@ -94,8 +94,7 @@ advisory warning.
94
94
  The registration is written with `timeout: 5`. The harness's documented
95
95
  default is 600s, and a hook that times out **does not block** — only a verdict
96
96
  already on stdout does. Five seconds is several orders of magnitude above this
97
- hook's measured cost (a single-pass lexer, no process spawns, no I/O beyond
98
- one `statSync` per PATH entry).
97
+ hook's measured cost (a bounded lexer and a 500 ms identity check against a verified package binary).
99
98
 
100
99
  ## Known limitations
101
100
 
@@ -111,8 +110,7 @@ The guard is a best-effort **text** classifier, not an execution sandbox.
111
110
  here.
112
111
  - **A hook only ever sees the agent's own tool calls.** It cannot stop a file
113
112
  being deleted by another process, by a build tool, by a script the agent
114
- runs, or by `unlink(2)` called directly. Coverage is the Bash tool, wave 1,
115
- nothing else — `Write`/`Edit` pre-image capture is wave 2.
113
+ runs, or by `unlink(2)` called directly. Bash `rm` is rewritten, and native `apply_patch` whole-file deletion is refused with an instruction to call Trash first. Ordinary `Write`/`Edit` pre-image capture is not implemented.
116
114
  - **Nested and generated commands escape it.** `bash -c`, `eval`, `make`,
117
115
  `npm run`, a `Dockerfile`, a heredoc-fed interpreter: the hook can only see
118
116
  that a shell string mentions a delete verb and refuse it, never redirect
@@ -128,5 +126,22 @@ The guard is a best-effort **text** classifier, not an execution sandbox.
128
126
  ## Configuration
129
127
 
130
128
  None. The home directory comes from `os.homedir()`; the trash binary is
131
- resolved by scanning `PATH` for an executable `trash` and rewriting to the
129
+ resolved by scanning `PATH` for a verified `@hasna/trash` package and its `--identity` protocol and rewriting to the
132
130
  absolute path found, so the rewritten command does not depend on `PATH` again.
131
+
132
+ ## Native Codex and Claude
133
+
134
+ The guard emits their documented `PreToolUse` decision contract, including a
135
+ complete `updatedInput.command`. No-op hooks emit no output. Codex unified exec
136
+ also matches `Bash`; a `Delete File` patch must use `trash put` first. Configure
137
+ Codex in `~/.codex/hooks.json` with matcher
138
+ `^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$` and the command
139
+ `hooks run trash-guard`, with hook timeout 5 seconds. Preserve other registrations
140
+ and refuse a second overlapping input-rewriting hook. Claude registration uses
141
+ `hooks install trash-guard --target claude`. The command rewrite defaults to
142
+ 600 seconds for upload and verification, preserving an explicitly supplied timeout.
143
+
144
+ After installation, prove the native harness actually executes the rewritten
145
+ command using a disposable file and a hosted entry/restore receipt; a hook JSON
146
+ response alone does not prove interception. Other harnesses can use the Trash
147
+ CLI/MCP directly; this hook does not claim their native interception.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trash-guard",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Codewith-native Trash Guard hook for @hasna/hooks",
5
5
  "type": "module",
6
6
  "main": "./src/hook.ts",
@@ -47,8 +47,9 @@
47
47
  */
48
48
 
49
49
  import { homedir } from "os";
50
- import { isAbsolute, join, normalize, resolve, sep } from "path";
51
- import { statSync } from "fs";
50
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
51
+ import { readFileSync, realpathSync, statSync } from "fs";
52
+ import { spawnSync } from "node:child_process";
52
53
  import {
53
54
  SYSTEM_PROTECTED_ROOTS,
54
55
  getCommand,
@@ -63,10 +64,10 @@ const RULE = "trash-guard";
63
64
 
64
65
  /**
65
66
  * Default Bash-tool timeout (ms) re-supplied on the rewrite. The rewritten
66
- * command is a same-device move, not a byte copy, so it is fast; an explicit
67
+ * command uploads and verifies the hosted capsule before source cleanup; an explicit
67
68
  * value keeps the rewritten tool_input complete.
68
69
  */
69
- const DEFAULT_TIMEOUT_MS = 120000;
70
+ const DEFAULT_TIMEOUT_MS = 600000;
70
71
 
71
72
  /** Description supplied only when the model did not provide one. */
72
73
  const DEFAULT_DESCRIPTION = "Delete via trash guard (rm intercepted and made recoverable)";
@@ -864,22 +865,37 @@ function gitHasDryRun(segment: Segment, verbIndex: number): boolean {
864
865
  /* ------------------------------------------------------------------ */
865
866
 
866
867
  /**
867
- * Locate the `trash` executable on PATH and return its absolute path, or null
868
- * when there is nothing to redirect to. Presence only — the guard decision
869
- * never waits on the binary, its store, or a network.
868
+ * A command called trash may be Apple's unrelated system utility. Resolve
869
+ * package provenance before executing a bounded, credential-free identity
870
+ * probe. Never invoke an unknown executable merely to discover what it is.
870
871
  */
871
872
  export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
872
873
  const pathValue = env.PATH ?? "";
873
- for (const dir of pathValue.split(":")) {
874
- if (!dir) continue;
875
- // resolve(): a relative PATH entry still yields the absolute path the
876
- // rewrite must use, so the rewritten command never depends on PATH again.
874
+ const deadline = Date.now() + 2_000;
875
+ for (const dir of pathValue.split(":").slice(0, 64)) {
876
+ if (!isAbsolute(dir) || Date.now() >= deadline) continue;
877
877
  const candidate = resolve(dir, "trash");
878
878
  try {
879
879
  const stat = statSync(candidate);
880
- if (stat.isFile() && (stat.mode & 0o111) !== 0) return candidate;
880
+ if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) continue;
881
+ const executable = realpathSync(candidate);
882
+ const packageRoot = resolve(dirname(executable), "../..");
883
+ const manifestPath = join(packageRoot, "package.json");
884
+ const manifestStat = statSync(manifestPath);
885
+ if (!manifestStat.isFile() || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0) continue;
886
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
887
+ if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
888
+ typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) continue;
889
+ const probe = spawnSync(candidate, ["--identity"], {
890
+ encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
891
+ env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
892
+ });
893
+ if (probe.error || probe.status !== 0) continue;
894
+ const identity = JSON.parse(probe.stdout);
895
+ if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
896
+ identity.guardProtocol === "hasna.trash.guard.v1") return candidate;
881
897
  } catch {
882
- // not there — keep looking
898
+ // Missing, unrelated, malformed or unresponsive packages are not targets.
883
899
  }
884
900
  }
885
901
  return null;
@@ -1004,10 +1020,18 @@ function resolveTrash(deps: GuardDependencies): { path: string | null; error: st
1004
1020
  }
1005
1021
 
1006
1022
  const ABSENT_REASON =
1007
- "[trash-guard] `trash` is not on PATH, so this `rm` cannot be redirected into a recoverable delete — and an unrecoverable delete is never allowed, so the command is refused rather than run. Install @hasna/trash (`npm install -g @hasna/trash`), which puts the binary on PATH, then re-run the same command: it is then rewritten to `trash guard` and the files land in trash instead of disappearing.";
1023
+ "[trash-guard] No verified @hasna/trash guard was found on PATH. This deletion is refused. Install the current @hasna/trash package with Bun and run its setup; the operating-system trash utility is not a compatible guard.";
1008
1024
 
1009
1025
  export function evaluate(input: CodewithHookInput, deps: GuardDependencies): CodewithHookOutput {
1010
1026
  if (input.hook_event_name !== "PreToolUse") return { continue: true };
1027
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name ?? "")) {
1028
+ const patch = input.tool_input?.command;
1029
+ if (typeof patch !== "string") return deny("[trash-guard] Unreadable patch input; deletion safety cannot be checked.");
1030
+ if (/^\s*\*\*\* Delete File:/m.test(patch)) {
1031
+ return deny("[trash-guard] Delete File would bypass recoverable deletion. First use `trash put -- <path>` or the trash_put MCP tool, then submit any remaining edits without the deletion block.");
1032
+ }
1033
+ return { continue: true };
1034
+ }
1011
1035
  if (input.tool_name !== "Bash") return { continue: true };
1012
1036
 
1013
1037
  // A command we cannot READ is a payload we cannot verify, and the harness
@@ -1089,7 +1113,7 @@ export function evaluate(input: CodewithHookInput, deps: GuardDependencies): Cod
1089
1113
 
1090
1114
  /** Verdict used when the hook itself fails: refuse anything that could delete. */
1091
1115
  export function fallbackVerdict(command: string): CodewithHookOutput {
1092
- return mentionsDeleteVerb(command)
1116
+ return mentionsDeleteVerb(command) || /^\s*\*\*\* Delete File:/m.test(command)
1093
1117
  ? deny(
1094
1118
  "[trash-guard] The hook failed while classifying this command, so it cannot be proven free of an unredirected delete. Re-run the delete as a plain `rm -- <path>` command.",
1095
1119
  )
@@ -1101,10 +1125,14 @@ export async function run(): Promise<void> {
1101
1125
  const command = getCommand(input);
1102
1126
  try {
1103
1127
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
1104
- respond(evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary }));
1128
+ const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary });
1129
+ // Native Codex rejects `continue` in PreToolUse JSON. Silence is the
1130
+ // documented no-op for both Codex and Claude; emit only actual decisions.
1131
+ if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
1105
1132
  } catch (error) {
1106
1133
  warn(`${RULE} failed: ${error instanceof Error ? error.message : String(error)}`);
1107
- respond(fallbackVerdict(command));
1134
+ const verdict = fallbackVerdict(command);
1135
+ if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
1108
1136
  }
1109
1137
  }
1110
1138
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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": {
7
7
  "hooks": "bin/index.js",
8
+ "hooks-mcp": "bin/hooks-mcp.js",
8
9
  "hooks-serve": "bin/serve.js"
9
10
  },
10
11
  "exports": {
@@ -12,6 +13,10 @@
12
13
  "types": "./dist/index.d.ts",
13
14
  "import": "./dist/index.js"
14
15
  },
16
+ "./sdk": {
17
+ "types": "./dist/sdk/index.d.ts",
18
+ "import": "./dist/sdk/index.js"
19
+ },
15
20
  "./storage": {
16
21
  "types": "./dist/storage.d.ts",
17
22
  "import": "./dist/storage.js"
@@ -20,7 +25,7 @@
20
25
  "main": "./dist/index.js",
21
26
  "types": "./dist/index.d.ts",
22
27
  "scripts": {
23
- "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
28
+ "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/mcp/hooks-mcp.ts --outdir ./bin --target bun --external pg --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/sdk/index.ts --outdir ./dist/sdk --target bun && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
24
29
  "build:types": "tsc -p tsconfig.build.json",
25
30
  "dev": "bun run ./src/cli/index.tsx",
26
31
  "test": "bun test",
@@ -173,15 +173,27 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
173
173
  const dataDir = join(workspace, "data");
174
174
  const binIndex = join(packageDir, "bin", "index.js");
175
175
  const binServe = join(packageDir, "bin", "serve.js");
176
+ const binMcp = join(packageDir, "bin", "hooks-mcp.js");
177
+ const sdkBundle = join(packageDir, "dist", "sdk", "index.js");
176
178
  if (!existsSync(binIndex)) throw new Error(`packed tarball is missing bin/index.js (CLI bin)`);
177
179
  if (!existsSync(binServe)) throw new Error(`packed tarball is missing bin/serve.js (serve bin)`);
180
+ if (!existsSync(binMcp)) throw new Error(`packed tarball is missing bin/hooks-mcp.js (MCP bin)`);
181
+ if (!existsSync(sdkBundle)) throw new Error(`packed tarball is missing dist/sdk/index.js (./sdk export)`);
178
182
 
183
+ // Hermetic route: the smoke lanes below exercise the on-box store on
184
+ // purpose, so they opt in explicitly (hasna/apps#1720 — without the
185
+ // opt-in `hooks run` and `hooks-mcp` fail closed, which the fail-closed
186
+ // lane checks separately) and keep the station's Keychain out of it.
179
187
  const env = {
180
188
  ...process.env,
189
+ HASNA_STATION: "no-such-station",
190
+ HASNA_HOOKS_LOCAL: "1",
181
191
  HASNA_HOOKS_DATA_DIR: dataDir,
182
192
  HASNA_HOOKS_DB_PATH: join(dataDir, "hooks.db"),
183
193
  NO_COLOR: "1",
184
194
  };
195
+ const failClosedEnv = { ...env };
196
+ delete (failClosedEnv as Record<string, string | undefined>).HASNA_HOOKS_LOCAL;
185
197
 
186
198
  // 1. CLI help.
187
199
  const help = spawnSync("bun", ["run", binIndex, "--help"], { cwd: smokeDir, env, encoding: "utf8" });
@@ -210,8 +222,22 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
210
222
  await new Promise((r) => setTimeout(r, 100));
211
223
  }
212
224
 
213
- // 3. MCP stdio startup: an initialize handshake must get a response.
214
- const mcpProc = spawn("bun", ["run", binIndex, "mcp", "--stdio"], {
225
+ // 2b. Fail-closed lane (hasna/apps#1720): with nothing configured the
226
+ // packed CLI and the packed hooks-mcp bin exit non-zero, name the
227
+ // credential tiers + the opt-in on stderr, and create no local store.
228
+ const failClosedHome = join(workspace, "failclosed-home");
229
+ const fcEnv = { ...failClosedEnv, HOME: failClosedHome, HASNA_HOOKS_DATA_DIR: join(failClosedHome, "data"), HASNA_HOOKS_DB_PATH: join(failClosedHome, "data", "hooks.db") };
230
+ for (const [label, argv] of [["packed CLI `hooks categories`", [binIndex, "categories"]], ["packed hooks-mcp", [binMcp]]] as Array<[string, string[]]>) {
231
+ const fc = spawnSync("bun", ["run", ...argv], { cwd: smokeDir, env: fcEnv, encoding: "utf8", input: "", timeout: 20000 });
232
+ if (fc.status === 0) throw new Error(`${label} exited 0 with nothing configured (must fail closed)`);
233
+ if (!/HASNA_HOOKS_LOCAL=1/.test(fc.stderr) || !/hasna\.credentials\.hooks\.api-key/.test(fc.stderr)) {
234
+ throw new Error(`${label} refusal did not name the tiers + opt-in: ${fc.stderr.slice(0, 300)}`);
235
+ }
236
+ if (existsSync(join(failClosedHome, "data", "hooks.db"))) throw new Error(`${label} created hooks.db while failing closed`);
237
+ }
238
+
239
+ // 3. MCP stdio startup (explicit local opt-in): an initialize handshake must get a response.
240
+ const mcpProc = spawn("bun", ["run", binMcp], {
215
241
  cwd: smokeDir,
216
242
  env,
217
243
  stdio: ["pipe", "pipe", "pipe"],
@@ -252,11 +278,12 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
252
278
  // pg link is best-effort; the import below will fail loudly if needed.
253
279
  }
254
280
  const sdkSmoke = join(smokeDir, "sdk-smoke.ts");
255
- await writeFile(sdkSmoke, `import { HOOKS, getStorageStatus } from "@hasna/hooks";\nimport { getStorageStatus as ss } from "@hasna/hooks/storage";\nconsole.log(JSON.stringify({ count: HOOKS.length, backend: getStorageStatus().backend, ss: ss().backend }));\n`);
281
+ await writeFile(sdkSmoke, `import { HOOKS, getStorageStatus } from "@hasna/hooks";\nimport { getStorageStatus as ss } from "@hasna/hooks/storage";\nimport { HooksClient, createHooksClient } from "@hasna/hooks/sdk";\nlet sdk = "threw";\ntry { createHooksClient({ env: { HASNA_STATION: "no-such-station" }, credentials: { keychain: { enabled: false } } }); sdk = "returned"; } catch (e) { sdk = /REMOTE_API_/.test(String(e)) ? "fail-closed" : "threw"; }\nconsole.log(JSON.stringify({ count: HOOKS.length, backend: getStorageStatus().backend, ss: ss().backend, sdk, hasClient: typeof HooksClient === "function" }));\n`);
256
282
  const sdk = spawnSync("bun", ["run", sdkSmoke], { cwd: smokeDir, env, encoding: "utf8", timeout: 20000 });
257
283
  if (sdk.status !== 0) throw new Error(`packed SDK import failed: ${sdk.stderr}`);
258
- const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string };
284
+ const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string; sdk: string; hasClient: boolean };
259
285
  if (typeof sdkOut.count !== "number" || sdkOut.count <= 0) throw new Error(`packed SDK import returned count ${sdkOut.count}`);
286
+ if (sdkOut.sdk !== "fail-closed" || !sdkOut.hasClient) throw new Error(`packed ./sdk export did not fail closed with nothing configured: ${JSON.stringify(sdkOut)}`);
260
287
 
261
288
  // 5. One bundled-hook run from the packed artifact (isolated data dir;
262
289
  // first run self-trusts, then executes).