@hasna/hooks 0.9.0 → 0.9.2
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 +43 -5
- package/bin/hooks-mcp.js +4980 -0
- package/bin/index.js +586 -252
- package/bin/serve.js +34 -12
- package/dist/db/index.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3016 -2782
- package/dist/lib/codex-settings.d.ts +3 -0
- package/dist/lib/db-writer.d.ts +30 -1
- package/dist/lib/installer.d.ts +1 -1
- package/dist/lib/local-opt-in.d.ts +26 -0
- package/dist/lib/sync.d.ts +17 -1
- package/dist/sdk/index.d.ts +103 -0
- package/dist/sdk/index.js +1050 -0
- package/dist/storage.js +15 -1
- package/hooks/hook-mementos-context/README.md +80 -0
- package/hooks/hook-mementos-context/src/hook.ts +116 -0
- package/hooks/hook-trash-guard/README.md +20 -5
- package/hooks/hook-trash-guard/package.json +1 -1
- package/hooks/hook-trash-guard/src/hook.ts +45 -17
- package/package.json +11 -5
- package/scripts/validate-package.ts +31 -4
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();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Mementos prompt context
|
|
2
|
+
|
|
3
|
+
Optional `UserPromptSubmit` context for Codex, Claude Code and Codewith. Mementos
|
|
4
|
+
retrieves existing memories from an explicitly resolved project. If decision
|
|
5
|
+
assistance is enabled, the configured provider judges relevance; it does not
|
|
6
|
+
generate memories, authorize actions or change stored records.
|
|
7
|
+
|
|
8
|
+
Install compatible `@hasna/mementos` and `@hasna/hooks` CLIs, then register:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
hooks install mementos-context --target codex
|
|
12
|
+
# Review the exact registration in Codex /hooks before trusting it.
|
|
13
|
+
# Claude Code: use --target claude.
|
|
14
|
+
# Codewith: --target codewith emits a TOML fragment for its config owner.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Codex registration writes `~/.codex/hooks.json` (or `.codex/hooks.json` with
|
|
18
|
+
`--project`). It preserves unrelated entries and refuses malformed or changed
|
|
19
|
+
settings. It does not change `config.toml`, project trust or native hook trust.
|
|
20
|
+
Codex skips an untrusted or modified definition until reviewed. `--target all`
|
|
21
|
+
retains the existing Claude/Codewith install group; select Codex explicitly.
|
|
22
|
+
Use `hooks list --registered --target codex` and
|
|
23
|
+
`hooks remove mementos-context --target codex` to inspect or remove registration.
|
|
24
|
+
|
|
25
|
+
The hook does nothing until its calling process sets `HOOKS_MEMENTOS_ENABLED=1`.
|
|
26
|
+
For example, start a fresh Codex CLI from a shell with:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
export HOOKS_MEMENTOS_ENABLED=1
|
|
30
|
+
export HOOKS_MEMENTOS_PROJECT=my-project
|
|
31
|
+
codex
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`my-project` must resolve to an existing Mementos project ID, name or registered
|
|
35
|
+
path. Without this override, the native event's `cwd` is the project reference.
|
|
36
|
+
An unknown project returns no context. Shared memories span sessions; the native
|
|
37
|
+
session ID is not automatically applied as a retrieval filter. Existing running
|
|
38
|
+
clients do not acquire newly exported environment variables.
|
|
39
|
+
|
|
40
|
+
Provider assistance is independently opt-in. For OpenRouter:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
mementos decisions configure --provider openrouter --model typesafe/jev-1.13
|
|
44
|
+
mementos decisions enable --retrieval
|
|
45
|
+
export HOOKS_MEMENTOS_OPENROUTER_SECRET_REF=example/openrouter/key
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The reference must name an existing key available through the optional Secrets
|
|
49
|
+
CLI. The hook invokes `secrets exec <reference> --as OPENROUTER_API_KEY -- mementos
|
|
50
|
+
prompt-context ...`; the key reaches only the Mementos consumer. Do not place key
|
|
51
|
+
values in hook commands, profiles or settings. Hooks' environment credential
|
|
52
|
+
filter remains in force. Without a provider or key, baseline retrieval works.
|
|
53
|
+
Provider enablement permits sending the redacted prompt and bounded memory
|
|
54
|
+
excerpts to that provider; redaction is heuristic, so select the intended scope.
|
|
55
|
+
|
|
56
|
+
| Environment variable (prefix `HOOKS_MEMENTOS_`) | Default / meaning |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `ENABLED` | Unset; only `1` enables execution |
|
|
59
|
+
| `PROJECT` | Native event `cwd`; explicit registered project reference |
|
|
60
|
+
| `SCOPE` | `shared`; `private` or `working` require both filters below |
|
|
61
|
+
| `AGENT_ID`, `SESSION_ID` | Optional explicit Mementos retrieval filters |
|
|
62
|
+
| `TAGS` | Optional JSON array of up to 10 tags |
|
|
63
|
+
| `MAX_ITEMS`, `MAX_CANDIDATES` | 3 selected / 12 retrieved; bounds 10 / 20 |
|
|
64
|
+
| `MAX_TOKENS` | 1000, estimated as UTF-8 bytes / 4; hard maximum 8192 bytes |
|
|
65
|
+
| `MIN_RELEVANCE` | 0.5; applied only to successful provider judgments |
|
|
66
|
+
| `TIMEOUT_MS` | 3500; total child deadline adds 1000 ms, maximum 11000 ms |
|
|
67
|
+
| `COMMAND` | `mementos`; optional executable path, never a shell expression |
|
|
68
|
+
| `OPENROUTER_SECRET_REF` | Unset; optional Secrets key reference |
|
|
69
|
+
|
|
70
|
+
The hook reads at most 32 KiB of native input and 24 KiB of CLI output, inserts at
|
|
71
|
+
most 8 KiB of context, and always continues the prompt. Provider failure falls
|
|
72
|
+
back to the retrieved order; retrieval/configuration failure inserts nothing.
|
|
73
|
+
Context is quoted JSON with record IDs, versions, scope and source, explicitly
|
|
74
|
+
labelled untrusted reference data. Jev scores are advisory and are not guaranteed
|
|
75
|
+
to be identical across repeated requests.
|
|
76
|
+
|
|
77
|
+
Mementos owns the retrieval operation (`mementos prompt-context --help`). Hooks
|
|
78
|
+
owns registration and execution. No watcher, channel consumer or web interface
|
|
79
|
+
is required. Installed registration, native trust and actual event execution are
|
|
80
|
+
separate states; use a native prompt event to verify the final integration.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const INPUT_BYTES = 32768;
|
|
4
|
+
const OUTPUT_BYTES = 24576;
|
|
5
|
+
const CONTEXT_BYTES = 8192;
|
|
6
|
+
type HookOutput = { continue: true; hookSpecificOutput?: { hookEventName: "UserPromptSubmit"; additionalContext: string } };
|
|
7
|
+
type Invocation = { command: string; args: string[]; input: string; timeoutMs: number };
|
|
8
|
+
type Executor = (invocation: Invocation) => Promise<string | null>;
|
|
9
|
+
const pass = (): HookOutput => ({ continue: true });
|
|
10
|
+
|
|
11
|
+
function numberOption(env: NodeJS.ProcessEnv, name: string, fallback: number, min: number, max: number, integer = true): number {
|
|
12
|
+
const raw = env[`HOOKS_MEMENTOS_${name}`];
|
|
13
|
+
const value = raw === undefined ? fallback : Number(raw);
|
|
14
|
+
if (!Number.isFinite(value) || (integer && !Number.isInteger(value)) || value < min || value > max) throw new Error("invalid_configuration");
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildInvocation(raw: unknown, env: NodeJS.ProcessEnv): Invocation | null {
|
|
19
|
+
if (env.HOOKS_MEMENTOS_ENABLED !== "1" || !raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
20
|
+
const event = raw as Record<string, unknown>;
|
|
21
|
+
if (event.hook_event_name !== "UserPromptSubmit" || typeof event.prompt !== "string" || !event.prompt.trim() || event.prompt.length > 4096) return null;
|
|
22
|
+
const project = env.HOOKS_MEMENTOS_PROJECT ?? event.cwd;
|
|
23
|
+
if (typeof project !== "string" || !project.trim() || project.length > 4096 || /[\u0000-\u001f]/.test(project)) return null;
|
|
24
|
+
const timeout = numberOption(env, "TIMEOUT_MS", 3500, 100, 10000);
|
|
25
|
+
// Shared memory spans sessions. Native session_id is deliberately not a retrieval filter.
|
|
26
|
+
const input = JSON.stringify({
|
|
27
|
+
prompt: event.prompt, project, scope: env.HOOKS_MEMENTOS_SCOPE ?? "shared",
|
|
28
|
+
agent_id: env.HOOKS_MEMENTOS_AGENT_ID, session_id: env.HOOKS_MEMENTOS_SESSION_ID,
|
|
29
|
+
tags: env.HOOKS_MEMENTOS_TAGS === undefined ? undefined : JSON.parse(env.HOOKS_MEMENTOS_TAGS),
|
|
30
|
+
});
|
|
31
|
+
if (Buffer.byteLength(input) > INPUT_BYTES) return null;
|
|
32
|
+
const args = ["prompt-context", "--enabled", "--input", "-",
|
|
33
|
+
"--max-items", String(numberOption(env, "MAX_ITEMS", 3, 1, 10)),
|
|
34
|
+
"--max-candidates", String(numberOption(env, "MAX_CANDIDATES", 12, 1, 20)),
|
|
35
|
+
"--max-tokens", String(numberOption(env, "MAX_TOKENS", 1000, 128, 2048)),
|
|
36
|
+
"--min-relevance", String(numberOption(env, "MIN_RELEVANCE", 0.5, 0, 1, false)),
|
|
37
|
+
"--timeout-ms", String(timeout)];
|
|
38
|
+
const command = env.HOOKS_MEMENTOS_COMMAND ?? "mementos";
|
|
39
|
+
// This is one executable, never a shell expression or a command-line string.
|
|
40
|
+
if (!command || /[\u0000-\u001f]/.test(command)) return null;
|
|
41
|
+
const secretRef = env.HOOKS_MEMENTOS_OPENROUTER_SECRET_REF;
|
|
42
|
+
if (secretRef !== undefined) {
|
|
43
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*(?:\/[a-zA-Z0-9][a-zA-Z0-9_.-]*){1,12}$/.test(secretRef) || secretRef.length > 256) return null;
|
|
44
|
+
return { command: "secrets", args: ["exec", secretRef, "--as", "OPENROUTER_API_KEY", "--", command, ...args], input, timeoutMs: timeout + 1000 };
|
|
45
|
+
}
|
|
46
|
+
return { command, args, input, timeoutMs: timeout + 1000 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Bound only the freshly spawned Mementos/Secrets child tree owned by this invocation. */
|
|
50
|
+
export const execute: Executor = invocation => new Promise(resolve => {
|
|
51
|
+
let child: ReturnType<typeof spawn>;
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
+
let settled = false;
|
|
54
|
+
let size = 0;
|
|
55
|
+
const parts: Buffer[] = [];
|
|
56
|
+
const finish = (value: string | null) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
resolve(value);
|
|
61
|
+
};
|
|
62
|
+
const abort = () => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
try {
|
|
65
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
66
|
+
else child.kill("SIGKILL");
|
|
67
|
+
} catch { /* child may already have exited */ }
|
|
68
|
+
finish(null);
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
child = spawn(invocation.command, invocation.args, {
|
|
72
|
+
stdio: ["pipe", "pipe", "ignore"], shell: false, detached: process.platform !== "win32", env: process.env,
|
|
73
|
+
});
|
|
74
|
+
timer = setTimeout(abort, invocation.timeoutMs);
|
|
75
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
76
|
+
size += chunk.length;
|
|
77
|
+
if (size > OUTPUT_BYTES) abort();
|
|
78
|
+
else parts.push(Buffer.from(chunk));
|
|
79
|
+
});
|
|
80
|
+
child.stdin!.on("error", abort);
|
|
81
|
+
child.on("error", () => finish(null));
|
|
82
|
+
child.on("close", code => finish(code === 0 ? Buffer.concat(parts).toString("utf8") : null));
|
|
83
|
+
child.stdin!.end(invocation.input);
|
|
84
|
+
} catch { finish(null); }
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export async function buildHookOutput(raw: unknown, executor: Executor = execute, env: NodeJS.ProcessEnv = process.env): Promise<HookOutput> {
|
|
88
|
+
try {
|
|
89
|
+
const invocation = buildInvocation(raw, env);
|
|
90
|
+
if (!invocation) return pass();
|
|
91
|
+
const output = await executor(invocation);
|
|
92
|
+
if (!output || Buffer.byteLength(output) > OUTPUT_BYTES) return pass();
|
|
93
|
+
const receipt = JSON.parse(output);
|
|
94
|
+
if (receipt?.contract !== "mementos.prompt-context.v1" || receipt.status !== "ready" || receipt.advisory !== true
|
|
95
|
+
|| typeof receipt.context !== "string" || !receipt.context || Buffer.byteLength(receipt.context) > CONTEXT_BYTES
|
|
96
|
+
|| receipt.context_bytes !== Buffer.byteLength(receipt.context) || !Array.isArray(receipt.items) || receipt.items.length < 1 || receipt.items.length > 10) return pass();
|
|
97
|
+
return { continue: true, hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: receipt.context } };
|
|
98
|
+
} catch { return pass(); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function run(): Promise<void> {
|
|
102
|
+
let output: HookOutput = pass();
|
|
103
|
+
try {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
for await (const chunk of process.stdin) {
|
|
107
|
+
bytes += chunk.length;
|
|
108
|
+
if (bytes > INPUT_BYTES) throw new Error("input_limit");
|
|
109
|
+
chunks.push(Buffer.from(chunk));
|
|
110
|
+
}
|
|
111
|
+
output = await buildHookOutput(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
112
|
+
} catch { /* Hook failures never block the user's prompt. */ }
|
|
113
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (import.meta.main) void run();
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
@@ -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
|
|
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 =
|
|
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
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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()
|
|
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
|
-
//
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
|
@@ -84,12 +89,13 @@
|
|
|
84
89
|
"!hooks/**/*.test.ts",
|
|
85
90
|
"!hooks/**/tsconfig.json"
|
|
86
91
|
],
|
|
87
|
-
"homepage": "https://github.com/hasna/hooks#readme",
|
|
92
|
+
"homepage": "https://github.com/hasna/apps/tree/main/apps/hooks#readme",
|
|
88
93
|
"bugs": {
|
|
89
|
-
"url": "https://github.com/hasna/
|
|
94
|
+
"url": "https://github.com/hasna/apps/issues"
|
|
90
95
|
},
|
|
91
96
|
"repository": {
|
|
92
97
|
"type": "git",
|
|
93
|
-
"url": "
|
|
98
|
+
"url": "https://github.com/hasna/apps.git",
|
|
99
|
+
"directory": "apps/hooks"
|
|
94
100
|
}
|
|
95
101
|
}
|
|
@@ -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
|
-
//
|
|
214
|
-
|
|
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).
|