@hasna/hooks 0.8.0 → 0.9.0
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/bin/index.js +315 -67
- package/bin/serve.js +12 -0
- package/dist/index.js +52 -4
- package/dist/lib/installer.d.ts +40 -1
- package/dist/lib/registration.d.ts +75 -0
- package/dist/lib/registry.d.ts +14 -0
- package/hooks/hook-agent-rules-version-check/README.md +1 -1
- package/hooks/hook-trash-guard/README.md +132 -0
- package/hooks/hook-trash-guard/package.json +12 -0
- package/hooks/hook-trash-guard/src/hook.ts +1114 -0
- package/package.json +4 -2
- package/hooks/codewith-native-common.test.ts +0 -1935
- package/hooks/hook-affected-tests/tsconfig.json +0 -25
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
- package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
- package/hooks/hook-announce-start/tsconfig.json +0 -25
- package/hooks/hook-announce-stop/tsconfig.json +0 -25
- package/hooks/hook-autoformat/tsconfig.json +0 -25
- package/hooks/hook-branchprotect/tsconfig.json +0 -25
- package/hooks/hook-checkbugs/tsconfig.json +0 -15
- package/hooks/hook-checkdocs/tsconfig.json +0 -15
- package/hooks/hook-checkfiles/tsconfig.json +0 -15
- package/hooks/hook-checklint/tsconfig.json +0 -15
- package/hooks/hook-checkpoint/tsconfig.json +0 -25
- package/hooks/hook-checksecurity/tsconfig.json +0 -15
- package/hooks/hook-checktasks/tsconfig.json +0 -20
- package/hooks/hook-checktests/tsconfig.json +0 -15
- package/hooks/hook-conflict-detect/tsconfig.json +0 -25
- package/hooks/hook-contextrefresh/tsconfig.json +0 -25
- package/hooks/hook-desktopnotify/tsconfig.json +0 -25
- package/hooks/hook-dm-inject/tsconfig.json +0 -25
- package/hooks/hook-envsetup/tsconfig.json +0 -25
- package/hooks/hook-failure-to-task/tsconfig.json +0 -25
- package/hooks/hook-filelock/tsconfig.json +0 -25
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
- package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
- package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
- package/hooks/hook-gitguard/tsconfig.json +0 -25
- package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
- package/hooks/hook-packageage/tsconfig.json +0 -25
- package/hooks/hook-permissionguard/tsconfig.json +0 -25
- package/hooks/hook-phonenotify/tsconfig.json +0 -25
- package/hooks/hook-precompact/tsconfig.json +0 -25
- package/hooks/hook-protectfiles/tsconfig.json +0 -25
- package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
- package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
- package/hooks/hook-stylescheck/tsconfig.json +0 -25
- package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
- package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
- package/hooks/mention-context/src/hook.test.ts +0 -68
package/dist/index.js
CHANGED
|
@@ -5998,6 +5998,18 @@ var HOOKS = [
|
|
|
5998
5998
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
5999
5999
|
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
6000
6000
|
},
|
|
6001
|
+
{
|
|
6002
|
+
name: "trash-guard",
|
|
6003
|
+
displayName: "Trash Guard",
|
|
6004
|
+
description: "Rewrites rm issued through Bash into `trash guard`, so the delete lands in a recoverable trash store; refuses the delete when there is nothing to redirect to (trash absent, a delete verb it cannot rewrite, or the protected class: /, ~, ~/.hasna, ~/.ssh, ~/.aws)",
|
|
6005
|
+
version: "0.1.0",
|
|
6006
|
+
category: "Git Safety",
|
|
6007
|
+
event: "PreToolUse",
|
|
6008
|
+
matcher: "Bash",
|
|
6009
|
+
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
6010
|
+
rewritesInput: true,
|
|
6011
|
+
timeoutSeconds: 5
|
|
6012
|
+
},
|
|
6001
6013
|
{
|
|
6002
6014
|
name: "checktests",
|
|
6003
6015
|
displayName: "Check Tests",
|
|
@@ -6566,6 +6578,13 @@ function resolveHook(name) {
|
|
|
6566
6578
|
|
|
6567
6579
|
// src/lib/installer.ts
|
|
6568
6580
|
init_manifest();
|
|
6581
|
+
|
|
6582
|
+
// src/lib/registration.ts
|
|
6583
|
+
function matchersOverlap(a, b) {
|
|
6584
|
+
return a === b || a.includes(b) || b.includes(a);
|
|
6585
|
+
}
|
|
6586
|
+
|
|
6587
|
+
// src/lib/installer.ts
|
|
6569
6588
|
init_store();
|
|
6570
6589
|
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
6571
6590
|
var HOOKS_DIR = existsSync7(join7(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join7(__dirname2, "..", "..", "hooks") : join7(__dirname2, "..", "hooks");
|
|
@@ -6701,6 +6720,7 @@ function codewithTimeout(name) {
|
|
|
6701
6720
|
case "prompt-guard":
|
|
6702
6721
|
return 3;
|
|
6703
6722
|
case "worktree-guard":
|
|
6723
|
+
case "trash-guard":
|
|
6704
6724
|
case "stop-sync":
|
|
6705
6725
|
return 5;
|
|
6706
6726
|
default:
|
|
@@ -6719,6 +6739,8 @@ function codewithStatusMessage(name) {
|
|
|
6719
6739
|
return "Checking prompt safety";
|
|
6720
6740
|
case "worktree-guard":
|
|
6721
6741
|
return "Checking worktree safety";
|
|
6742
|
+
case "trash-guard":
|
|
6743
|
+
return "Redirecting rm into trash";
|
|
6722
6744
|
case "stop-sync":
|
|
6723
6745
|
return "Syncing turn-end heartbeat";
|
|
6724
6746
|
default:
|
|
@@ -6786,14 +6808,32 @@ function detectConflict(name, scope, target) {
|
|
|
6786
6808
|
continue;
|
|
6787
6809
|
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
6788
6810
|
continue;
|
|
6789
|
-
|
|
6790
|
-
const b = existing.matcher.toLowerCase();
|
|
6791
|
-
if (a === b || a.includes(b) || b.includes(a)) {
|
|
6811
|
+
if (matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
|
|
6792
6812
|
return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
|
|
6793
6813
|
}
|
|
6794
6814
|
}
|
|
6795
6815
|
return;
|
|
6796
6816
|
}
|
|
6817
|
+
function detectRewriteConflict(name, scope, target, lookup = getHook) {
|
|
6818
|
+
const meta = resolveHookMeta(name);
|
|
6819
|
+
if (!meta?.matcher || !meta.rewritesInput)
|
|
6820
|
+
return;
|
|
6821
|
+
if (!getHookEvents(meta).includes("PreToolUse"))
|
|
6822
|
+
return;
|
|
6823
|
+
for (const existingName of getRegisteredHooksForTarget(scope, target)) {
|
|
6824
|
+
if (existingName === name)
|
|
6825
|
+
continue;
|
|
6826
|
+
const existing = lookup(existingName);
|
|
6827
|
+
if (!existing?.matcher || !existing.rewritesInput)
|
|
6828
|
+
continue;
|
|
6829
|
+
if (!getHookEvents(existing).includes("PreToolUse"))
|
|
6830
|
+
continue;
|
|
6831
|
+
if (!matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase()))
|
|
6832
|
+
continue;
|
|
6833
|
+
return `both '${name}' and '${existingName}' rewrite the tool input on overlapping PreToolUse matchers ('${meta.matcher}' / '${existing.matcher}'). Only one rewrite per tool call is applied, so one guard would silently disappear \u2014 install one of them, or narrow a matcher so they no longer overlap.`;
|
|
6834
|
+
}
|
|
6835
|
+
return;
|
|
6836
|
+
}
|
|
6797
6837
|
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
6798
6838
|
const shortName = shortHookName(name);
|
|
6799
6839
|
if (!hookExists(shortName)) {
|
|
@@ -6853,6 +6893,10 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
|
|
|
6853
6893
|
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
6854
6894
|
}
|
|
6855
6895
|
const conflict = detectConflict(shortName, scope, target);
|
|
6896
|
+
const rewriteConflict = detectRewriteConflict(shortName, scope, target);
|
|
6897
|
+
if (rewriteConflict) {
|
|
6898
|
+
return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
|
|
6899
|
+
}
|
|
6856
6900
|
try {
|
|
6857
6901
|
registerHook(shortName, scope, target, profile);
|
|
6858
6902
|
return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
|
|
@@ -6924,8 +6968,12 @@ function registerHook(name, scope = "global", target = "claude", profile) {
|
|
|
6924
6968
|
for (const eventKey of uniqueEventKeys) {
|
|
6925
6969
|
if (!settings.hooks[eventKey])
|
|
6926
6970
|
settings.hooks[eventKey] = [];
|
|
6971
|
+
const hookEntry = { type: "command", command: hookCommand };
|
|
6972
|
+
if (typeof meta.timeoutSeconds === "number" && meta.timeoutSeconds > 0) {
|
|
6973
|
+
hookEntry.timeout = meta.timeoutSeconds;
|
|
6974
|
+
}
|
|
6927
6975
|
const entry = {
|
|
6928
|
-
hooks: [
|
|
6976
|
+
hooks: [hookEntry]
|
|
6929
6977
|
};
|
|
6930
6978
|
if (meta.matcher) {
|
|
6931
6979
|
entry.matcher = meta.matcher;
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Hooks run directly from the globally installed @hasna/hooks package.
|
|
10
10
|
* No files are copied. The settings entry points to `hooks run <name>`.
|
|
11
11
|
*/
|
|
12
|
-
import { type HookEvent } from "./registry.js";
|
|
12
|
+
import { type HookEvent, type HookMeta } from "./registry.js";
|
|
13
|
+
import { type StaleRegistration } from "./registration.js";
|
|
13
14
|
export type Scope = "global" | "project";
|
|
14
15
|
export type Target = "claude" | "gemini" | "codewith" | "all";
|
|
15
16
|
type SingleTarget = Exclude<Target, "all">;
|
|
@@ -47,6 +48,18 @@ export declare function hookExists(name: string): boolean;
|
|
|
47
48
|
/** Whether a hook's event can be registered for the given target */
|
|
48
49
|
export declare function isEventSupported(internalEvent: HookEvent, target: SingleTarget): boolean;
|
|
49
50
|
export declare function buildCodewithTomlFragment(name: string, profile?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* The one conflict class that BLOCKS an install: two PreToolUse hooks on
|
|
53
|
+
* overlapping matchers that both rewrite the tool input
|
|
54
|
+
* (`hookSpecificOutput.updatedInput`). The harness keeps ONE rewrite per tool
|
|
55
|
+
* call — last writer wins — so whichever guard loses is silently disarmed
|
|
56
|
+
* with no error anywhere. `trash-guard` is the first such hook.
|
|
57
|
+
*
|
|
58
|
+
* Every other conflict stays advisory: it still installs, with the warning.
|
|
59
|
+
*/
|
|
60
|
+
export declare function detectRewriteConflict(name: string, scope: Scope, target: SingleTarget,
|
|
61
|
+
/** Injected for tests: the rewrite claim is a registry concern, the overlap is not. */
|
|
62
|
+
lookup?: (hookName: string) => HookMeta | undefined): string | undefined;
|
|
50
63
|
export declare function installHook(name: string, options?: InstallOptions): InstallResult;
|
|
51
64
|
export declare function installHooks(names: string[], options?: InstallOptions): InstallResult[];
|
|
52
65
|
export declare function getRegisteredHooksForTarget(scope?: Scope, target?: SingleTarget): string[];
|
|
@@ -54,6 +67,32 @@ export declare function getRegisteredHooks(scope?: Scope): string[];
|
|
|
54
67
|
/** @deprecated Use getRegisteredHooks instead */
|
|
55
68
|
export declare const getInstalledHooks: typeof getRegisteredHooks;
|
|
56
69
|
export declare function removeHook(name: string, scope?: Scope, target?: Target): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Every settings registration for the scope whose hook name does not resolve.
|
|
72
|
+
*
|
|
73
|
+
* A stale registration makes the agent spawn `hooks run <name>` on every event
|
|
74
|
+
* it is wired to, and each spawn fails — the failure is invisible to `hooks
|
|
75
|
+
* install`/`hooks remove` because nothing re-checks names already written.
|
|
76
|
+
*/
|
|
77
|
+
export declare function scanStaleRegistrations(scope?: Scope): StaleRegistration[];
|
|
78
|
+
export interface PruneStaleResult {
|
|
79
|
+
/** Settings file edited. */
|
|
80
|
+
file: string;
|
|
81
|
+
/** The registrations removed from it. */
|
|
82
|
+
removed: StaleRegistration[];
|
|
83
|
+
/** Sibling backup of the pre-edit bytes (`<settings>.bak`). */
|
|
84
|
+
backupPath: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Remove every stale registration the scope carries, and nothing else.
|
|
88
|
+
*
|
|
89
|
+
* Edits only the entries whose own command is stale: sibling hooks in the same
|
|
90
|
+
* entry, unrelated event keys, matchers and every non-hook settings key are
|
|
91
|
+
* preserved, the file stays valid JSON, and a `.bak` sibling is written before
|
|
92
|
+
* the edit. Idempotent — a second call finds nothing stale and writes nothing.
|
|
93
|
+
* A registration whose hook resolves is never touched.
|
|
94
|
+
*/
|
|
95
|
+
export declare function pruneStaleRegistrations(scope?: Scope): PruneStaleResult[];
|
|
57
96
|
export interface UninstallResult {
|
|
58
97
|
name: string;
|
|
59
98
|
removed: boolean;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings registration checks shared by the CLI doctor and the MCP
|
|
3
|
+
* hooks_doctor.
|
|
4
|
+
*
|
|
5
|
+
* Event and matcher are separate settings fields — settings.hooks[event] is
|
|
6
|
+
* an array of { matcher?, hooks: [...] } entries — so a composite event such
|
|
7
|
+
* as 'PreToolUse:Bash' must be split before lookup, never used as a key.
|
|
8
|
+
*/
|
|
9
|
+
/** A settings registration whose hook name cannot resolve. */
|
|
10
|
+
export interface StaleRegistration {
|
|
11
|
+
/** Settings file the registration lives in. */
|
|
12
|
+
file: string;
|
|
13
|
+
/** Settings event key holding the entry (PreToolUse, …). */
|
|
14
|
+
event: string;
|
|
15
|
+
/** The registered hook name that does not resolve. */
|
|
16
|
+
hook: string;
|
|
17
|
+
/** The raw command string from the settings entry. */
|
|
18
|
+
command: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Find settings registrations whose hook does not resolve.
|
|
22
|
+
*
|
|
23
|
+
* A registration is a `hooks run <name>` (or legacy `hook-<name>`) command;
|
|
24
|
+
* it is stale when `resolveHook(name)` finds no bundled, custom or stored
|
|
25
|
+
* hook — exactly the lookup `hooks run` performs, so a stale registration
|
|
26
|
+
* fails on every tool call it is wired to. Direct-path wiring outside the
|
|
27
|
+
* installer's own command forms is reported by `countSettingsWiring`, never
|
|
28
|
+
* treated as a hook registration.
|
|
29
|
+
*/
|
|
30
|
+
export declare function findStaleRegistrations(settings: Record<string, unknown>, file: string, resolves?: (name: string) => boolean): StaleRegistration[];
|
|
31
|
+
/**
|
|
32
|
+
* Whether two tool matchers can select the same tool call. Deliberately loose
|
|
33
|
+
* (identical, or one a substring of the other) — a false positive costs one
|
|
34
|
+
* advisory warning, a false negative costs a silently disarmed guard.
|
|
35
|
+
*/
|
|
36
|
+
export declare function matchersOverlap(a: string, b: string): boolean;
|
|
37
|
+
/** The metadata slice `findRewriteOverlaps` needs from a hook. */
|
|
38
|
+
export interface RewriteHookInfo {
|
|
39
|
+
matcher?: string;
|
|
40
|
+
event?: string;
|
|
41
|
+
events?: string[];
|
|
42
|
+
rewritesInput?: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** Two installed hooks that both rewrite the tool input on the same tool. */
|
|
45
|
+
export interface RewriteOverlap {
|
|
46
|
+
hooks: [string, string];
|
|
47
|
+
event: string;
|
|
48
|
+
matchers: [string, string];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Installed hooks that both rewrite the tool input
|
|
52
|
+
* (`hookSpecificOutput.updatedInput`) on an overlapping PreToolUse matcher.
|
|
53
|
+
*
|
|
54
|
+
* This is a real defect, not a style nit: the harness applies ONE rewrite per
|
|
55
|
+
* tool call, so one of the two guards is silently disarmed. The installer
|
|
56
|
+
* refuses the pairing up front; doctor reports it for settings files it did
|
|
57
|
+
* not write (hand-edited, or written before the rule existed).
|
|
58
|
+
*/
|
|
59
|
+
export declare function findRewriteOverlaps(names: string[], lookup: (name: string) => RewriteHookInfo | undefined): RewriteOverlap[];
|
|
60
|
+
/**
|
|
61
|
+
* Whether a hook is registered in a settings file.
|
|
62
|
+
*
|
|
63
|
+
* An entry matches when it carries a `hooks run <name>` command and, when the
|
|
64
|
+
* hook declares a matcher, the entry's matcher is absent (fires on all tools)
|
|
65
|
+
* or consistent with the hook's matcher (equal, or one regex matches the
|
|
66
|
+
* other).
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Count every raw hook entry wired in a settings file — including
|
|
70
|
+
* direct-path entries that never went through the CLI (`command` values that
|
|
71
|
+
* are not `hooks run <name>`). Doctor reports this count alongside the
|
|
72
|
+
* registered count so its "healthy" verdict names its bounds (P2-16b).
|
|
73
|
+
*/
|
|
74
|
+
export declare function countSettingsWiring(settings: Record<string, unknown>): number;
|
|
75
|
+
export declare function hookRegisteredInSettings(settings: Record<string, unknown>, name: string, eventSpec: string, hookMatcher: string): boolean;
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -13,6 +13,20 @@ export interface HookMeta {
|
|
|
13
13
|
events?: HookEvent[];
|
|
14
14
|
matcher: string;
|
|
15
15
|
tags: string[];
|
|
16
|
+
/**
|
|
17
|
+
* This hook rewrites the tool input (`hookSpecificOutput.updatedInput`).
|
|
18
|
+
* The agent harness keeps ONE rewrite per tool call — last writer wins — so
|
|
19
|
+
* two input-rewriting hooks on overlapping PreToolUse matchers silently
|
|
20
|
+
* disarm each other. Install refuses that pairing, and `hooks doctor`
|
|
21
|
+
* reports it.
|
|
22
|
+
*/
|
|
23
|
+
rewritesInput?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Timeout (seconds) written into the agent's settings entry for this hook.
|
|
26
|
+
* The harness default is 600s, and a TIMED-OUT HOOK DOES NOT BLOCK: a guard
|
|
27
|
+
* that needs a verdict on stdout must declare a timeout it can meet.
|
|
28
|
+
*/
|
|
29
|
+
timeoutSeconds?: number;
|
|
16
30
|
}
|
|
17
31
|
export declare const CATEGORIES: readonly ["Git Safety", "Code Quality", "Security", "Notifications", "Context Management", "Workflow Automation", "Environment", "Permissions", "Observability", "Agent Teams"];
|
|
18
32
|
export type Category = (typeof CATEGORIES)[number];
|
|
@@ -43,7 +43,7 @@ export HOOKS_FLEET_TIMEOUT_MS=500 # configs CLI timeout (default 5
|
|
|
43
43
|
|
|
44
44
|
## Requirements
|
|
45
45
|
|
|
46
|
-
- `configs` CLI (@hasna/configs) — optional; without it the hook falls back to cross-artifact consistency checking
|
|
46
|
+
- `configs` CLI (@hasna/instructions — the package that ships the `configs` bin) — optional; without it the hook falls back to cross-artifact consistency checking
|
|
47
47
|
|
|
48
48
|
## Event
|
|
49
49
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# trash-guard
|
|
2
|
+
|
|
3
|
+
Codewith-native hook installed as `hooks run trash-guard`.
|
|
4
|
+
|
|
5
|
+
PreToolUse guard for `rm` issued through the Bash tool. It rewrites the verb
|
|
6
|
+
into `@hasna/trash`'s guard subcommand — `<abs>/trash guard <same args>` — so
|
|
7
|
+
the delete lands in a trash store and stays recoverable. When there is nothing
|
|
8
|
+
to redirect to, it **refuses** the command.
|
|
9
|
+
|
|
10
|
+
## The decision, and why it fails the way it does
|
|
11
|
+
|
|
12
|
+
The block decision is **self-contained**: it never waits on the trash store, a
|
|
13
|
+
network, a credential or the `@hasna/trash` package. The binary is consulted
|
|
14
|
+
only as an opportunistic upgrade:
|
|
15
|
+
|
|
16
|
+
| `trash` on PATH | verdict |
|
|
17
|
+
|---|---|
|
|
18
|
+
| present | `permissionDecision: "allow"` + `updatedInput` rewriting `rm` to `<abs>/trash guard` |
|
|
19
|
+
| absent | `permissionDecision: "deny"` + a reason telling the agent to install `@hasna/trash` |
|
|
20
|
+
|
|
21
|
+
There is no third row. It degrades **redirect → block**, never
|
|
22
|
+
**redirect → allow**: a delete this hook cannot redirect is never silently run.
|
|
23
|
+
|
|
24
|
+
The rewrite emits a **complete** `tool_input` — every key the model supplied,
|
|
25
|
+
plus `command`, `description`, `timeout` and `run_in_background`, plus
|
|
26
|
+
`dangerouslyDisableSandbox` when it was present. That care is deliberate: the
|
|
27
|
+
harness falls back to the **original** tool input when `updatedInput` is
|
|
28
|
+
missing or empty, so a partial rewrite would run the raw `rm`. The hook
|
|
29
|
+
re-scans the rewritten text before allowing it, and if the re-scan still finds
|
|
30
|
+
a live delete verb it denies instead. A command containing both an owned `rm`
|
|
31
|
+
and one handed to another guard is refused rather than partially rewritten.
|
|
32
|
+
|
|
33
|
+
## What it rewrites
|
|
34
|
+
|
|
35
|
+
Only `rm`'s own grammar, and only in **command position**: the verb must be the
|
|
36
|
+
command, after any assignment prefixes (`FOO=1 rm …`), shell keywords, and
|
|
37
|
+
recognized wrappers (`sudo`, `doas`, `env`, `nice`, `ionice`, `stdbuf`, `time`,
|
|
38
|
+
`timeout`, `nohup`, `setsid`, `command`, `builtin`, `exec`). Quoting, `~`,
|
|
39
|
+
`$HOME` and `${HOME}` spellings, `--`, redirections and every other byte of the
|
|
40
|
+
command are preserved.
|
|
41
|
+
|
|
42
|
+
Wrappers that take positional arguments are handled (`timeout 5 rm -rf x`
|
|
43
|
+
rewrites the `rm`, not the duration).
|
|
44
|
+
|
|
45
|
+
## What it refuses
|
|
46
|
+
|
|
47
|
+
- `rmdir`, `unlink`, `shred` — their flags and semantics are not `rm`'s.
|
|
48
|
+
- `git rm` (without `--cached`), `git clean` — they delete outside `rm`.
|
|
49
|
+
- `find -delete`, `find -exec rm …`, `xargs rm` — the delete does not run
|
|
50
|
+
through a verb this hook can swap.
|
|
51
|
+
- A delete inside a command substitution (`$(rm …)`, backticks).
|
|
52
|
+
- `busybox rm` / `toybox rm` — a different applet, not GNU `rm`.
|
|
53
|
+
- Shells and opaque command strings (`sh -c 'rm …'`, `eval`, `su -c …`).
|
|
54
|
+
- An unparseable command (unterminated quote, substitution or here-document)
|
|
55
|
+
that mentions a delete verb.
|
|
56
|
+
- The protected class, below.
|
|
57
|
+
|
|
58
|
+
Each refusal carries a reason and a way forward: re-run the delete as
|
|
59
|
+
`rm -- <path>`, which this hook intercepts and redirects.
|
|
60
|
+
|
|
61
|
+
## The protected class
|
|
62
|
+
|
|
63
|
+
Refused outright, never redirected (plan §15 decision 11.3):
|
|
64
|
+
|
|
65
|
+
- the filesystem root and the system roots `pre-bash`'s protected-path rules
|
|
66
|
+
already name (`/etc`, `/usr`, `/bin`, `/home`, `/var`, …) — the root itself,
|
|
67
|
+
or any ancestor of it;
|
|
68
|
+
- the home directory itself (`~`, `$HOME`, `${HOME}`);
|
|
69
|
+
- `~/.hasna`, `~/.ssh`, `~/.aws` — the state and credential stores, including
|
|
70
|
+
everything under them.
|
|
71
|
+
|
|
72
|
+
These are the catastrophic cases where "move it to trash" is not an acceptable
|
|
73
|
+
answer. Everything else this hook owns is rewritten, not refused.
|
|
74
|
+
|
|
75
|
+
## Scope: what it deliberately does NOT own
|
|
76
|
+
|
|
77
|
+
Deletes under the protected repo-checkout roots `$HOME/.hasna/repos/clones` and
|
|
78
|
+
`$HOME/workspace/repos` belong to **`workspace-repos-guard`**, which blocks
|
|
79
|
+
every delete under them at any depth. This hook does not restate that policy:
|
|
80
|
+
it recognizes the boundary and abstains, so the other hook's decision stands.
|
|
81
|
+
A command that mixes a delete inside those roots with one outside is refused,
|
|
82
|
+
because a partial rewrite would leave one of them unredirected.
|
|
83
|
+
|
|
84
|
+
## Conflict discipline
|
|
85
|
+
|
|
86
|
+
`trash-guard` declares `rewritesInput: true` in the registry. Two PreToolUse
|
|
87
|
+
hooks on overlapping matchers that both rewrite the tool input are a silent
|
|
88
|
+
data-loss hazard: the harness keeps one rewrite (last writer wins), so the
|
|
89
|
+
losing hook's guard disappears with no error. Installing a second such hook is
|
|
90
|
+
therefore **refused**, and `hooks doctor` reports more than one input-rewriting
|
|
91
|
+
hook on an overlapping matcher. Other overlaps still install with the usual
|
|
92
|
+
advisory warning.
|
|
93
|
+
|
|
94
|
+
The registration is written with `timeout: 5`. The harness's documented
|
|
95
|
+
default is 600s, and a hook that times out **does not block** — only a verdict
|
|
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).
|
|
99
|
+
|
|
100
|
+
## Known limitations
|
|
101
|
+
|
|
102
|
+
The guard is a best-effort **text** classifier, not an execution sandbox.
|
|
103
|
+
|
|
104
|
+
- **Variable indirection is undetectable.** `R=...; rm -rf $R`, a loop over
|
|
105
|
+
computed paths, or a script downloaded and run at runtime cannot be
|
|
106
|
+
classified before the shell expands it. This is the same limitation
|
|
107
|
+
`workspace-repos-guard` documents, and it is inherent to inspecting the
|
|
108
|
+
command string rather than the syscall. An operand that is a variable or a
|
|
109
|
+
glob is still **rewritten** (the `trash` binary classifies what the shell
|
|
110
|
+
actually expands to), but it cannot be checked against the protected class
|
|
111
|
+
here.
|
|
112
|
+
- **A hook only ever sees the agent's own tool calls.** It cannot stop a file
|
|
113
|
+
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.
|
|
116
|
+
- **Nested and generated commands escape it.** `bash -c`, `eval`, `make`,
|
|
117
|
+
`npm run`, a `Dockerfile`, a heredoc-fed interpreter: the hook can only see
|
|
118
|
+
that a shell string mentions a delete verb and refuse it, never redirect
|
|
119
|
+
inside it.
|
|
120
|
+
- **Threat model: accident, not adversary.** A hostile same-user agent can
|
|
121
|
+
remove the hook, edit the store, or call `unlink(2)` directly; nothing here
|
|
122
|
+
holds against that.
|
|
123
|
+
- **Fail-closed only for deletes.** On an internal error the hook denies a
|
|
124
|
+
command that mentions a delete verb and stays silent otherwise, so a guard
|
|
125
|
+
defect cannot wedge unrelated work. Fail-closed cannot be guaranteed where
|
|
126
|
+
the harness itself never delivers the hook input.
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
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
|
|
132
|
+
absolute path found, so the rewritten command does not depend on `PATH` again.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trash-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codewith-native Trash Guard hook for @hasna/hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|