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 CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.5.0 released** — Quality harness: `kj harden` brings the guardrails Karajan was built with to any repo in one command — idempotent git hooks, lint/format/commit config, CI quality gates and agent guidelines, all stack-aware and behind `kj:managed` markers that never overwrite your content. Native per-language hook commands mean hardening a Go/Python/Java repo never makes Node a commit-time dependency. `kj check` verifies the harness as a CI drift gate, and `kj init` installs it out of the box. Full notes in [CHANGELOG.md](CHANGELOG.md).
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.5.0",
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,10 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/cli.js";
3
+
4
+ runCli(process.argv.slice(2)).then(
5
+ (code) => process.exit(code ?? 0),
6
+ (err) => {
7
+ process.stderr.write(`kj-trash: ${err?.message ?? err}\n`);
8
+ process.exit(1);
9
+ }
10
+ );
@@ -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
+ }