karajan-code 3.5.0 → 3.6.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/README.md +1 -1
- package/package.json +13 -1
- package/packages/ai-trash/README.md +37 -0
- package/packages/ai-trash/bin/kj-trash.js +10 -0
- package/packages/ai-trash/package.json +43 -0
- package/packages/ai-trash/src/cli.js +198 -0
- package/packages/ai-trash/src/destructive-parser.js +74 -0
- package/packages/ai-trash/src/git-snapshot.js +128 -0
- package/packages/ai-trash/src/hook.js +137 -0
- package/packages/ai-trash/src/index.js +14 -0
- package/packages/ai-trash/src/install.js +52 -0
- package/packages/ai-trash/src/logger.js +60 -0
- package/packages/ai-trash/src/manifest.js +91 -0
- package/packages/ai-trash/src/permissions.js +26 -0
- package/packages/ai-trash/src/snapshot.js +66 -0
- package/packages/ai-trash/src/ulid.js +66 -0
- package/scripts/verify-pack.mjs +14 -1
- package/src/checks/ai-trash.js +1 -1
- package/src/cli/register-meta.js +8 -0
- package/src/cli.js +15 -1
- package/src/commands/harden.js +57 -2
- package/src/commands/init.js +77 -45
- package/src/harden/advisory.js +168 -0
- package/src/harden/config-templates.js +5 -0
- package/src/harden/hook-commands.js +5 -0
- package/src/harden/interactive.js +76 -0
- package/src/harden/stack-roots.js +35 -4
- package/src/harden/workflow-templates.js +8 -0
- package/src/utils/squeezr-detect.js +4 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v3.
|
|
26
|
+
> **v3.6.0 released** — Advisory harden: `kj harden --report` shows what the kj standard would add to an existing repo (per artifact: missing / yours / kj-managed, plus the concrete improvements), and `kj harden --interactive` lets you adopt it piece by piece, default-safe. Plus scope control (`--only`/`--exclude`, demo/fixture dirs ignored), a rounder `kj init` first run (consistent English, glossed terms, clean close), and the `kj-trash` safety net now actually ships to npm installs. Full notes in [CHANGELOG.md](CHANGELOG.md).
|
|
27
27
|
|
|
28
28
|
You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=22.22.1"
|
|
28
28
|
},
|
|
29
|
+
"kj": {
|
|
30
|
+
"harden": {
|
|
31
|
+
"exclude": [
|
|
32
|
+
"wrappers",
|
|
33
|
+
"packages"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
29
37
|
"workspaces": [
|
|
30
38
|
"packages/ai-trash",
|
|
31
39
|
"packages/core",
|
|
@@ -46,6 +54,9 @@
|
|
|
46
54
|
"packages/hu-board/src/",
|
|
47
55
|
"packages/hu-board/public/",
|
|
48
56
|
"packages/hu-board/package.json",
|
|
57
|
+
"packages/ai-trash/src/",
|
|
58
|
+
"packages/ai-trash/bin/",
|
|
59
|
+
"packages/ai-trash/package.json",
|
|
49
60
|
"docs/karajan-code-logo-small.png",
|
|
50
61
|
"docs/README.es.md",
|
|
51
62
|
"LICENSE",
|
|
@@ -54,6 +65,7 @@
|
|
|
54
65
|
"bin": {
|
|
55
66
|
"kj": "bin/kj.js",
|
|
56
67
|
"kj-tail": "bin/kj-tail",
|
|
68
|
+
"kj-trash": "packages/ai-trash/bin/kj-trash.js",
|
|
57
69
|
"karajan-mcp": "bin/karajan-mcp.js",
|
|
58
70
|
"kj-rag-mcp": "bin/kj-rag-mcp.js"
|
|
59
71
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @karajan/ai-trash
|
|
2
|
+
|
|
3
|
+
AI-proof trash: intercept destructive operations from an AI-driven shell
|
|
4
|
+
(`rm -rf`, truncation via `>`, `mv` overwriting, destructive `git` ops, …)
|
|
5
|
+
and snapshot the affected paths to a bin the AI cannot reach before the
|
|
6
|
+
command runs.
|
|
7
|
+
|
|
8
|
+
Status: **skeleton** — see Karajan epic `KJC-PCS-0041` and the planning
|
|
9
|
+
documents in `docs/ai-trash-fase1-report.md` + `docs/ai-trash-fase2-plan.md`
|
|
10
|
+
of the parent repo.
|
|
11
|
+
|
|
12
|
+
The MVP is being built incrementally (KJC-TSK-0388). This package currently
|
|
13
|
+
only ships the skeleton (`package.json`, `bin/kj-trash.js`, `src/index.js`).
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Any flow where an LLM has shell access (Claude Code hooks, Cursor, Aider,
|
|
18
|
+
OpenCode, Devin, custom LangChain/AutoGen agents …) shares one risk: the
|
|
19
|
+
agent can issue `rm -rf` on a critical path and there is no undo. `ai-trash`
|
|
20
|
+
puts a snapshot in a place the agent cannot read, write, or `empty` —
|
|
21
|
+
restore is one command away; emptying the bin requires a human factor
|
|
22
|
+
(sudo / TTY / external token).
|
|
23
|
+
|
|
24
|
+
## Roadmap
|
|
25
|
+
|
|
26
|
+
1. Manifest + trash-store (ULID, TTL, LRU quota) — KJC-TSK-0388 commit 2
|
|
27
|
+
2. Logger append-only + permissions (linux + macOS) — commit 3
|
|
28
|
+
3. File / directory snapshotter (mv + reflink fallback cp) — commit 4
|
|
29
|
+
4. CLI `list / inspect / restore` + `bin/kj-trash` — commit 5
|
|
30
|
+
5. CLI `empty / purge` with TTY guard — commit 6
|
|
31
|
+
6. Destructive git ops (`reset --hard`, `push --force`, …) — KJC-TSK-0389
|
|
32
|
+
7. Claude Code `PreToolUse` adapter — KJC-TSK-0390
|
|
33
|
+
8. Wiring into `kj doctor` — KJC-TSK-0391
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
AGPL-3.0 — same as Karajan Code.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karajan/ai-trash",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "AI-proof trash: intercept destructive ops from an AI shell and snapshot them to an unreachable bin before execution",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"author": "Manu Fosela",
|
|
8
|
+
"homepage": "https://github.com/manufosela/karajan-code/tree/main/packages/ai-trash#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/manufosela/karajan-code.git",
|
|
12
|
+
"directory": "packages/ai-trash"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/manufosela/karajan-code/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"trash",
|
|
20
|
+
"undo",
|
|
21
|
+
"snapshot",
|
|
22
|
+
"safety",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"cursor",
|
|
25
|
+
"aider"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.22.1"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"kj-trash": "bin/kj-trash.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"src/",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
},
|
|
42
|
+
"private": false
|
|
43
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import {
|
|
5
|
+
loadManifest,
|
|
6
|
+
saveManifest,
|
|
7
|
+
removeEntry,
|
|
8
|
+
expireBefore,
|
|
9
|
+
enforceLruQuota,
|
|
10
|
+
} from "./manifest.js";
|
|
11
|
+
import { restoreSnapshot, purgeSnapshot } from "./snapshot.js";
|
|
12
|
+
import { restoreGitBundle } from "./git-snapshot.js";
|
|
13
|
+
import { ensureSecureDir, assertOwnedByCurrentUser } from "./permissions.js";
|
|
14
|
+
import { runHook } from "./hook.js";
|
|
15
|
+
import { installClaudeCodeHook, DEFAULT_SETTINGS_PATH } from "./install.js";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_ROOT = process.env.AI_TRASH_ROOT || join(homedir(), ".ai-trash");
|
|
18
|
+
|
|
19
|
+
function parseFlags(argv) {
|
|
20
|
+
const flags = {};
|
|
21
|
+
const positional = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const tok = argv[i];
|
|
24
|
+
if (tok.startsWith("--")) {
|
|
25
|
+
const eq = tok.indexOf("=");
|
|
26
|
+
if (eq > -1) flags[tok.slice(2, eq)] = tok.slice(eq + 1);
|
|
27
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags[tok.slice(2)] = argv[++i];
|
|
28
|
+
else flags[tok.slice(2)] = true;
|
|
29
|
+
} else positional.push(tok);
|
|
30
|
+
}
|
|
31
|
+
return { positional, flags };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shortAge(ms) {
|
|
35
|
+
const sec = Math.max(1, Math.floor(ms / 1000));
|
|
36
|
+
if (sec < 60) return `${sec}s`;
|
|
37
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
38
|
+
if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
|
|
39
|
+
return `${Math.floor(sec / 86400)}d`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function ensureRoot(root) {
|
|
43
|
+
await ensureSecureDir(root);
|
|
44
|
+
await assertOwnedByCurrentUser(root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cmdList(root, out) {
|
|
48
|
+
await ensureRoot(root);
|
|
49
|
+
const m = await loadManifest(root);
|
|
50
|
+
if (!m.entries.length) {
|
|
51
|
+
out.write("kj-trash: (empty)\n");
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
out.write("ID AGE BYTES SOURCE\n");
|
|
56
|
+
for (const e of m.entries) {
|
|
57
|
+
out.write(
|
|
58
|
+
`${e.id} ${shortAge(now - e.createdAt).padStart(4)} ${String(e.sizeBytes).padStart(5)} ${e.sourcePath}\n`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function cmdInspect(root, id, out, err) {
|
|
65
|
+
await ensureRoot(root);
|
|
66
|
+
const m = await loadManifest(root);
|
|
67
|
+
const entry = m.entries.find((e) => e.id === id);
|
|
68
|
+
if (!entry) {
|
|
69
|
+
err.write(`kj-trash: no entry with id ${id}\n`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
out.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function cmdRestore(root, id, target, out, err) {
|
|
77
|
+
await ensureRoot(root);
|
|
78
|
+
const m = await loadManifest(root);
|
|
79
|
+
const entry = m.entries.find((e) => e.id === id);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
err.write(`kj-trash: no entry with id ${id}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
if (entry.type === "git-bundle") {
|
|
85
|
+
const res = await restoreGitBundle(root, entry, target);
|
|
86
|
+
out.write(
|
|
87
|
+
`kj-trash: fetched bundle ${id} -> ${res.repo} (${res.refCount} ref(s) at refs/remotes/restore/*)\n`
|
|
88
|
+
);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const written = await restoreSnapshot(root, entry, target);
|
|
92
|
+
removeEntry(m, id);
|
|
93
|
+
await saveManifest(root, m);
|
|
94
|
+
out.write(`kj-trash: restored ${id} -> ${written}\n`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function cmdPurge(root, id, out, err) {
|
|
99
|
+
await ensureRoot(root);
|
|
100
|
+
const m = await loadManifest(root);
|
|
101
|
+
const entry = m.entries.find((e) => e.id === id);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
err.write(`kj-trash: no entry with id ${id}\n`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
await purgeSnapshot(root, entry);
|
|
107
|
+
removeEntry(m, id);
|
|
108
|
+
await saveManifest(root, m);
|
|
109
|
+
out.write(`kj-trash: purged ${id}\n`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function cmdEmpty(root, flags, out) {
|
|
114
|
+
await ensureRoot(root);
|
|
115
|
+
const m = await loadManifest(root);
|
|
116
|
+
let dropped = [];
|
|
117
|
+
if (flags["older-than-days"]) {
|
|
118
|
+
const days = Number(flags["older-than-days"]);
|
|
119
|
+
if (!Number.isFinite(days) || days < 0) throw new Error("--older-than-days must be >= 0");
|
|
120
|
+
dropped = expireBefore(m, Date.now() - days * 86400 * 1000);
|
|
121
|
+
} else if (flags["max-bytes"]) {
|
|
122
|
+
const max = Number(flags["max-bytes"]);
|
|
123
|
+
if (!Number.isFinite(max) || max < 0) throw new Error("--max-bytes must be >= 0");
|
|
124
|
+
dropped = enforceLruQuota(m, max);
|
|
125
|
+
} else {
|
|
126
|
+
dropped = m.entries.slice();
|
|
127
|
+
m.entries = [];
|
|
128
|
+
}
|
|
129
|
+
for (const e of dropped) await purgeSnapshot(root, e);
|
|
130
|
+
await saveManifest(root, m);
|
|
131
|
+
out.write(`kj-trash: emptied ${dropped.length} snapshot(s)\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const HELP =
|
|
136
|
+
"usage: kj-trash <command> [args]\n" +
|
|
137
|
+
" list list snapshots oldest-first\n" +
|
|
138
|
+
" inspect <id> print snapshot metadata as JSON\n" +
|
|
139
|
+
" restore <id> [--to PATH] restore a snapshot (default: original path)\n" +
|
|
140
|
+
" purge <id> delete a single snapshot\n" +
|
|
141
|
+
" empty [--older-than-days N | --max-bytes N]\n" +
|
|
142
|
+
" drop all (or filtered) snapshots\n" +
|
|
143
|
+
" hook Claude Code PreToolUse handler (reads JSON\n" +
|
|
144
|
+
" on stdin, writes decision JSON on stdout)\n" +
|
|
145
|
+
" install --claude-code patch ~/.claude/settings.json (idempotent)\n" +
|
|
146
|
+
"env: AI_TRASH_ROOT (default ~/.ai-trash)\n";
|
|
147
|
+
|
|
148
|
+
export async function runCli(argv, io = {}) {
|
|
149
|
+
const out = io.out ?? process.stdout;
|
|
150
|
+
const err = io.err ?? process.stderr;
|
|
151
|
+
const root = io.root ?? DEFAULT_ROOT;
|
|
152
|
+
const { positional, flags } = parseFlags(argv);
|
|
153
|
+
const [cmd, arg1] = positional;
|
|
154
|
+
switch (cmd) {
|
|
155
|
+
case "list":
|
|
156
|
+
return cmdList(root, out);
|
|
157
|
+
case "inspect":
|
|
158
|
+
if (!arg1) {
|
|
159
|
+
err.write("kj-trash: inspect requires <id>\n");
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
return cmdInspect(root, arg1, out, err);
|
|
163
|
+
case "restore":
|
|
164
|
+
if (!arg1) {
|
|
165
|
+
err.write("kj-trash: restore requires <id>\n");
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
return cmdRestore(root, arg1, typeof flags.to === "string" ? flags.to : undefined, out, err);
|
|
169
|
+
case "purge":
|
|
170
|
+
if (!arg1) {
|
|
171
|
+
err.write("kj-trash: purge requires <id>\n");
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
return cmdPurge(root, arg1, out, err);
|
|
175
|
+
case "empty":
|
|
176
|
+
return cmdEmpty(root, flags, out);
|
|
177
|
+
case "hook":
|
|
178
|
+
return runHook({ root, stdin: io.stdin ?? process.stdin, stdout: out });
|
|
179
|
+
case "install": {
|
|
180
|
+
if (!flags["claude-code"]) {
|
|
181
|
+
err.write("kj-trash: install requires --claude-code\n");
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
const path = typeof flags.settings === "string" ? flags.settings : DEFAULT_SETTINGS_PATH;
|
|
185
|
+
const res = await installClaudeCodeHook({ path });
|
|
186
|
+
out.write(`kj-trash: ${res.mutated ? "installed" : "already installed"} -> ${res.path}\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
case "help":
|
|
190
|
+
case "--help":
|
|
191
|
+
case undefined:
|
|
192
|
+
out.write(HELP);
|
|
193
|
+
return cmd ? 0 : 2;
|
|
194
|
+
default:
|
|
195
|
+
err.write(`kj-trash: unknown command '${cmd}'\n${HELP}`);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Pure classifier for shell commands the PreToolUse hook receives.
|
|
2
|
+
// Recognises the destructive ops ai-trash will snapshot before they run.
|
|
3
|
+
// Conservative: anything unknown is left as safe, but every destructive
|
|
4
|
+
// shape returns kind+paths so the hook can snapshot before letting it run.
|
|
5
|
+
|
|
6
|
+
function tokenise(command) {
|
|
7
|
+
if (Array.isArray(command)) return command.filter((t) => typeof t === "string");
|
|
8
|
+
if (typeof command !== "string") return [];
|
|
9
|
+
const out = [];
|
|
10
|
+
let buf = "", quote = null;
|
|
11
|
+
for (const ch of command) {
|
|
12
|
+
if (quote) { if (ch === quote) quote = null; else buf += ch; continue; }
|
|
13
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
14
|
+
if (ch === " " || ch === "\t" || ch === "\n") { if (buf) { out.push(buf); buf = ""; } continue; }
|
|
15
|
+
buf += ch;
|
|
16
|
+
}
|
|
17
|
+
if (buf) out.push(buf);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rmFlags(tokens) {
|
|
22
|
+
const flags = tokens.filter((t) => t.startsWith("-")).join("");
|
|
23
|
+
return { recursive: flags.includes("r"), force: flags.includes("f") };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function classifyRm(tokens) {
|
|
27
|
+
const positional = tokens.slice(1).filter((t) => !t.startsWith("-"));
|
|
28
|
+
const { recursive, force } = rmFlags(tokens.slice(1));
|
|
29
|
+
return { destructive: true, kind: "rm", recursive, force, paths: positional, reason: "rm removes files" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function classifyMv(tokens) {
|
|
33
|
+
const positional = tokens.slice(1).filter((t) => !t.startsWith("-"));
|
|
34
|
+
if (positional.length < 2) return { destructive: false, kind: "mv-noop", paths: [], reason: "mv without source/target" };
|
|
35
|
+
return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classifyGit(tokens) {
|
|
39
|
+
const sub = tokens[1];
|
|
40
|
+
const has = (...flags) => flags.some((f) => tokens.includes(f));
|
|
41
|
+
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", paths: [], reason: "git reset --hard drops working tree" };
|
|
42
|
+
if (sub === "clean" && has("-f", "-fd", "-xdf")) return { destructive: true, kind: "git-clean", paths: [], reason: "git clean -f removes untracked files" };
|
|
43
|
+
if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", paths: [], reason: "git branch -D drops unmerged branch" };
|
|
44
|
+
if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", paths: [], reason: "git push --force rewrites remote history" };
|
|
45
|
+
if (sub === "checkout" && has("--", ".")) return { destructive: true, kind: "git-checkout-discard", paths: [], reason: "git checkout -- discards local edits" };
|
|
46
|
+
return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function classifyRedirect(tokens) {
|
|
50
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
51
|
+
if ((tokens[i] === ">" || tokens[i] === ">|") && tokens[i + 1]) {
|
|
52
|
+
return { destructive: true, kind: "redirect-clobber", paths: [tokens[i + 1]], reason: "> truncates the target file" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyCommand(command) {
|
|
59
|
+
const tokens = tokenise(command);
|
|
60
|
+
if (!tokens.length) return { destructive: false, kind: "empty", paths: [], reason: "no command" };
|
|
61
|
+
const redirect = classifyRedirect(tokens);
|
|
62
|
+
if (redirect) return redirect;
|
|
63
|
+
const head = tokens[0];
|
|
64
|
+
if (head === "rm") return classifyRm(tokens);
|
|
65
|
+
if (head === "truncate") {
|
|
66
|
+
const paths = tokens.slice(1).filter((t) => !t.startsWith("-") && !/^\d+$/.test(t));
|
|
67
|
+
return { destructive: true, kind: "truncate", paths, reason: "truncate rewrites file size" };
|
|
68
|
+
}
|
|
69
|
+
if (head === "mv" || head === "cp") return classifyMv(tokens);
|
|
70
|
+
if (head === "git") return classifyGit(tokens);
|
|
71
|
+
return { destructive: false, kind: "safe", paths: [], reason: `head '${head}' not in destructive list` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { tokenise };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Git snapshot primitive (KJC-TSK-0389 commit 1). Captures the full repo
|
|
2
|
+
// state via `git bundle create --all` before a destructive git op runs so
|
|
3
|
+
// the hook can restore lost commits/refs afterwards. The bundle includes
|
|
4
|
+
// every reachable commit + every ref (branches + tags), letting
|
|
5
|
+
// `git fetch --all <bundle>` rebuild HEAD + ref tips even after a
|
|
6
|
+
// `reset --hard`, `branch -D`, or `push --force`.
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from "node:fs";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { ulid } from "./ulid.js";
|
|
12
|
+
import { ensureSecureDir, lockdownFile } from "./permissions.js";
|
|
13
|
+
import { appendLogEntry } from "./logger.js";
|
|
14
|
+
|
|
15
|
+
function runGit(args, cwd) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
18
|
+
let stdout = "";
|
|
19
|
+
let stderr = "";
|
|
20
|
+
child.stdout.on("data", (b) => (stdout += b.toString("utf8")));
|
|
21
|
+
child.stderr.on("data", (b) => (stderr += b.toString("utf8")));
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function captureRefs(repoDir) {
|
|
28
|
+
const res = await runGit(["show-ref", "--head"], repoDir);
|
|
29
|
+
if (res.code !== 0) return [];
|
|
30
|
+
return res.stdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((line) => {
|
|
35
|
+
const [sha, ref] = line.split(/\s+/, 2);
|
|
36
|
+
return { sha, ref };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Snapshot the full state of a git repository as a `.bundle` file so the
|
|
42
|
+
* pre-op refs can be restored after a destructive op. Returns a manifest
|
|
43
|
+
* entry shaped like `snapshotFile`'s output so the manifest layer can
|
|
44
|
+
* store both file and git snapshots side by side.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} rootDir - ai-trash root (`<root>/store/<id>/repo.bundle`).
|
|
47
|
+
* @param {string} repoDir - target git repository.
|
|
48
|
+
* @param {{command?: string, origin?: string, label?: string}} [options]
|
|
49
|
+
* @returns {Promise<object>} manifest entry with type=`git-bundle`.
|
|
50
|
+
*/
|
|
51
|
+
export async function snapshotGitBundle(rootDir, repoDir, options = {}) {
|
|
52
|
+
const insideRepo = await runGit(["rev-parse", "--is-inside-work-tree"], repoDir);
|
|
53
|
+
if (insideRepo.code !== 0 || insideRepo.stdout.trim() !== "true") {
|
|
54
|
+
throw new Error(`ai-trash: not a git repo: ${repoDir}`);
|
|
55
|
+
}
|
|
56
|
+
const id = ulid();
|
|
57
|
+
const storeDir = join(rootDir, "store", id);
|
|
58
|
+
await ensureSecureDir(storeDir);
|
|
59
|
+
const bundlePath = join(storeDir, "repo.bundle");
|
|
60
|
+
const bundle = await runGit(["bundle", "create", bundlePath, "--all"], repoDir);
|
|
61
|
+
if (bundle.code !== 0) {
|
|
62
|
+
throw new Error(`ai-trash: git bundle failed: ${bundle.stderr.trim()}`);
|
|
63
|
+
}
|
|
64
|
+
await lockdownFile(bundlePath);
|
|
65
|
+
const refs = await captureRefs(repoDir);
|
|
66
|
+
const stat = await fs.stat(bundlePath);
|
|
67
|
+
const entry = {
|
|
68
|
+
id,
|
|
69
|
+
type: "git-bundle",
|
|
70
|
+
createdAt: Date.now(),
|
|
71
|
+
sourcePath: repoDir,
|
|
72
|
+
snapshotPath: bundlePath,
|
|
73
|
+
sizeBytes: stat.size,
|
|
74
|
+
refs,
|
|
75
|
+
command: options.command ?? null,
|
|
76
|
+
origin: options.origin ?? "unknown",
|
|
77
|
+
label: options.label ?? null,
|
|
78
|
+
};
|
|
79
|
+
await appendLogEntry(rootDir, "snapshot.git-bundle", {
|
|
80
|
+
id,
|
|
81
|
+
repoDir,
|
|
82
|
+
bundlePath,
|
|
83
|
+
sizeBytes: stat.size,
|
|
84
|
+
refCount: refs.length,
|
|
85
|
+
});
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Replay a `git-bundle` snapshot into a target repository. Runs
|
|
91
|
+
* `git fetch <bundlePath> +refs/heads/*:refs/remotes/restore/*` so the
|
|
92
|
+
* pre-op refs land under `restore/*` and can be inspected, checked out,
|
|
93
|
+
* or `reset --hard`-ed back without clobbering anything in the current
|
|
94
|
+
* working tree. Leaves the manifest entry intact so repeated fetches
|
|
95
|
+
* remain possible (the bundle is not deleted).
|
|
96
|
+
*
|
|
97
|
+
* @param {string} rootDir - ai-trash root (for audit).
|
|
98
|
+
* @param {object} entry - manifest entry with type `git-bundle`.
|
|
99
|
+
* @param {string} [targetRepo] - destination repo (default: entry.sourcePath).
|
|
100
|
+
* @returns {Promise<{ repo: string, refspec: string, refCount: number }>}
|
|
101
|
+
*/
|
|
102
|
+
export async function restoreGitBundle(rootDir, entry, targetRepo) {
|
|
103
|
+
if (entry?.type !== "git-bundle") {
|
|
104
|
+
throw new Error(`ai-trash: not a git-bundle entry: ${entry?.id ?? "<missing>"}`);
|
|
105
|
+
}
|
|
106
|
+
const repo = targetRepo ?? entry.sourcePath;
|
|
107
|
+
const insideRepo = await runGit(["rev-parse", "--is-inside-work-tree"], repo);
|
|
108
|
+
if (insideRepo.code !== 0 || insideRepo.stdout.trim() !== "true") {
|
|
109
|
+
throw new Error(`ai-trash: restore target is not a git repo: ${repo}`);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await fs.stat(entry.snapshotPath);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`ai-trash: bundle missing on disk: ${entry.snapshotPath}`);
|
|
115
|
+
}
|
|
116
|
+
const refspec = "+refs/heads/*:refs/remotes/restore/*";
|
|
117
|
+
const fetched = await runGit(["fetch", entry.snapshotPath, refspec], repo);
|
|
118
|
+
if (fetched.code !== 0) {
|
|
119
|
+
throw new Error(`ai-trash: git fetch failed: ${fetched.stderr.trim()}`);
|
|
120
|
+
}
|
|
121
|
+
await appendLogEntry(rootDir, "snapshot.restore-bundle", {
|
|
122
|
+
id: entry.id,
|
|
123
|
+
bundlePath: entry.snapshotPath,
|
|
124
|
+
targetRepo: repo,
|
|
125
|
+
refCount: entry.refs?.length ?? 0,
|
|
126
|
+
});
|
|
127
|
+
return { repo, refspec, refCount: entry.refs?.length ?? 0 };
|
|
128
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Claude Code PreToolUse hook (KJC-TSK-0390 commit 2). Reads the JSON
|
|
2
|
+
// payload from stdin, classifies the Bash command, snapshots target
|
|
3
|
+
// paths, prints the decision JSON on stdout. Fail-closed: if snapshot
|
|
4
|
+
// throws, deny the op so Claude never destroys without a recovery copy.
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { classifyCommand } from "./destructive-parser.js";
|
|
10
|
+
import { loadManifest, saveManifest } from "./manifest.js";
|
|
11
|
+
import { snapshotFile } from "./snapshot.js";
|
|
12
|
+
import { snapshotGitBundle } from "./git-snapshot.js";
|
|
13
|
+
import { ensureSecureDir, assertOwnedByCurrentUser } from "./permissions.js";
|
|
14
|
+
import { appendLogEntry } from "./logger.js";
|
|
15
|
+
|
|
16
|
+
function reply(decision, reason) {
|
|
17
|
+
return {
|
|
18
|
+
hookSpecificOutput: {
|
|
19
|
+
hookEventName: "PreToolUse",
|
|
20
|
+
permissionDecision: decision,
|
|
21
|
+
permissionDecisionReason: reason,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readJsonStdin(stdin) {
|
|
27
|
+
let raw = "";
|
|
28
|
+
for await (const chunk of stdin) raw += chunk;
|
|
29
|
+
if (!raw.trim()) return null;
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function snapshotExistingPaths(root, paths, cwd, command) {
|
|
34
|
+
const snapshots = [];
|
|
35
|
+
for (const p of paths) {
|
|
36
|
+
const abs = isAbsolute(p) ? p : resolve(cwd ?? process.cwd(), p);
|
|
37
|
+
try {
|
|
38
|
+
const stat = await fs.lstat(abs);
|
|
39
|
+
if (stat.isDirectory()) continue;
|
|
40
|
+
const entry = await snapshotFile(root, abs, { command, origin: "claude-code" });
|
|
41
|
+
snapshots.push(entry);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === "ENOENT") continue;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return snapshots;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function snapshotGitRepo(root, cwd, command, kind) {
|
|
51
|
+
if (!cwd) return { snapshots: [], skipped: "no cwd" };
|
|
52
|
+
try {
|
|
53
|
+
const entry = await snapshotGitBundle(root, cwd, {
|
|
54
|
+
command,
|
|
55
|
+
origin: "claude-code",
|
|
56
|
+
label: kind,
|
|
57
|
+
});
|
|
58
|
+
return { snapshots: [entry], skipped: null };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (/not a git repo/.test(err.message)) {
|
|
61
|
+
return { snapshots: [], skipped: "cwd is not a git repo" };
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleHookPayload(payload, { root, stdin }) {
|
|
68
|
+
const data = payload ?? (await readJsonStdin(stdin));
|
|
69
|
+
if (!data) return reply("allow", "ai-trash: empty payload, deferring");
|
|
70
|
+
if (data.hook_event_name !== "PreToolUse") {
|
|
71
|
+
return reply("allow", `ai-trash: not PreToolUse (${data.hook_event_name})`);
|
|
72
|
+
}
|
|
73
|
+
if (data.tool_name !== "Bash")
|
|
74
|
+
return reply("allow", `ai-trash: tool '${data.tool_name}' not handled`);
|
|
75
|
+
|
|
76
|
+
const command = data.tool_input?.command;
|
|
77
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
78
|
+
return reply("allow", "ai-trash: empty Bash command");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const verdict = classifyCommand(command);
|
|
82
|
+
if (!verdict.destructive) return reply("allow", `ai-trash: ${verdict.kind} (${verdict.reason})`);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await ensureSecureDir(root);
|
|
86
|
+
await assertOwnedByCurrentUser(root);
|
|
87
|
+
let snapshots = [];
|
|
88
|
+
let skipped = null;
|
|
89
|
+
if (verdict.kind.startsWith("git-")) {
|
|
90
|
+
({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
|
|
91
|
+
} else {
|
|
92
|
+
snapshots = await snapshotExistingPaths(root, verdict.paths, data.cwd, command);
|
|
93
|
+
}
|
|
94
|
+
if (snapshots.length) {
|
|
95
|
+
const m = await loadManifest(root);
|
|
96
|
+
for (const s of snapshots) m.entries.push(s);
|
|
97
|
+
await saveManifest(root, m);
|
|
98
|
+
}
|
|
99
|
+
await appendLogEntry(root, "hook.allow", {
|
|
100
|
+
kind: verdict.kind,
|
|
101
|
+
command,
|
|
102
|
+
cwd: data.cwd ?? null,
|
|
103
|
+
snapshotCount: snapshots.length,
|
|
104
|
+
sessionId: data.session_id ?? null,
|
|
105
|
+
});
|
|
106
|
+
if (snapshots.length) {
|
|
107
|
+
return reply(
|
|
108
|
+
"allow",
|
|
109
|
+
`ai-trash: snapshotted ${snapshots.length} ${verdict.kind.startsWith("git-") ? "git bundle" : "path(s)"} before ${verdict.kind}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return reply(
|
|
113
|
+
"allow",
|
|
114
|
+
skipped
|
|
115
|
+
? `ai-trash: ${verdict.kind} (${skipped})`
|
|
116
|
+
: `ai-trash: ${verdict.kind} (no existing paths to snapshot)`
|
|
117
|
+
);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
try {
|
|
120
|
+
await appendLogEntry(root, "hook.deny", {
|
|
121
|
+
kind: verdict.kind,
|
|
122
|
+
command,
|
|
123
|
+
error: err.message,
|
|
124
|
+
sessionId: data.session_id ?? null,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
/* root unwritable; deny is still the right answer */
|
|
128
|
+
}
|
|
129
|
+
return reply("deny", `ai-trash: snapshot failed (${err.message}); blocking ${verdict.kind}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function runHook({ root, stdin = process.stdin, stdout = process.stdout }) {
|
|
134
|
+
const response = await handleHookPayload(null, { root, stdin, stdout });
|
|
135
|
+
stdout.write(JSON.stringify(response) + "\n");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|