karajan-code 4.37.0 → 4.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.37.0",
3
+ "version": "4.39.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",
@@ -35,14 +35,43 @@ function classifyMv(tokens) {
35
35
  return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
36
36
  }
37
37
 
38
+ // KJC-BUG-0240 (#1886): checkout and restore drop uncommitted work file by file,
39
+ // and a bundle keeps commits only, so the named files are kept as files and a
40
+ // discard of the whole tree marks it (worktree) for the hook to keep every dirty file.
41
+ function classifyDiscard(tokens, sub) {
42
+ const args = tokens.slice(2);
43
+ const has = (...flags) => flags.some((f) => args.includes(f));
44
+ const SAFE = { destructive: false, kind: "git-safe", paths: [] };
45
+ if (sub === "restore" && has("--staged", "-S") && !has("--worktree", "-W")) return { ...SAFE, reason: "restore --staged only unstages" };
46
+ // -B resets an existing branch: its commits need the bundle.
47
+ if (sub === "checkout" && has("-B")) return { destructive: true, kind: "git-checkout-reset-branch", bundle: true, paths: [], reason: "git checkout -B resets an existing branch" };
48
+ if (sub === "checkout" && has("-b", "--orphan")) return { ...SAFE, reason: "checkout -b creates a branch" };
49
+ const dd = args.indexOf("--");
50
+ const named = [];
51
+ let sourceValue = false;
52
+ args.forEach((a, i) => {
53
+ if (sourceValue) sourceValue = false;
54
+ else if (a === "-s" || a === "--source") sourceValue = true;
55
+ else if ((dd >= 0 && i > dd) || !a.startsWith("-")) named.push(a);
56
+ });
57
+ if (!named.length && !has("-f", "--force")) return { ...SAFE, reason: `${sub} with nothing to discard` };
58
+ const whole = has("-f", "--force") || named.includes(".") || named.includes(":/");
59
+ return { destructive: true, kind: `git-${sub}-discard`, paths: named.filter((p) => p !== "." && p !== ":/"), ...(whole ? { worktree: "tracked" } : {}), reason: `git ${sub} discards local edits` };
60
+ }
61
+
38
62
  function classifyGit(tokens) {
39
63
  const sub = tokens[1];
40
64
  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" };
65
+ if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", bundle: true, worktree: "tracked", paths: [], reason: "git reset --hard drops working tree" };
66
+ if (sub === "clean" && tokens.some((t) => t === "--force" || /^-[a-zA-Z]*f/.test(t))) {
67
+ // -x / -X also remove ignored files.
68
+ const ignored = tokens.some((t) => /^-[a-zA-Z]*[xX]/.test(t));
69
+ return { destructive: true, kind: "git-clean", bundle: true, worktree: ignored ? "untracked+ignored" : "untracked", paths: [], reason: "git clean -f removes untracked files" };
70
+ }
71
+ if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", bundle: true, paths: [], reason: "git branch -D drops unmerged branch" };
72
+ if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", bundle: true, paths: [], reason: "git push --force rewrites remote history" };
73
+ if (sub === "checkout" || sub === "restore") return classifyDiscard(tokens, sub);
74
+ if (sub === "switch" && has("-f", "--force", "--discard-changes")) return { destructive: true, kind: "git-switch-discard", worktree: "tracked", paths: [], reason: "git switch --discard-changes drops local edits" };
46
75
  return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
47
76
  }
48
77
 
@@ -4,6 +4,7 @@
4
4
  // throws, deny the op so Claude never destroys without a recovery copy.
5
5
 
6
6
  import { promises as fs } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { isAbsolute, resolve } from "node:path";
8
9
  import process from "node:process";
9
10
  import { classifyCommand } from "./destructive-parser.js";
@@ -47,6 +48,29 @@ async function snapshotExistingPaths(root, paths, cwd, command) {
47
48
  return snapshots;
48
49
  }
49
50
 
51
+ // KJC-BUG-0240 (#1886): the dirty files a whole-tree discard would drop, kept as
52
+ // files (a bundle keeps commits only). `which`: "tracked" changes, "untracked"
53
+ // files, or "untracked+ignored" (git clean -x/-X removes ignored files too).
54
+ async function snapshotDirtyFiles(root, cwd, command, which) {
55
+ if (!cwd) return [];
56
+ const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
57
+ if (top.status !== 0) return [];
58
+ const withIgnored = which === "untracked+ignored";
59
+ // traditional + all: every file inside an ignored directory is listed one by one.
60
+ const st = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all", ...(withIgnored ? ["--ignored=traditional"] : [])], { encoding: "utf8" });
61
+ if (st.status !== 0) throw new Error(`git status failed: ${st.stderr.trim()}`);
62
+ const wanted = (rec) => (which === "tracked" ? !rec.startsWith("??") && !rec.startsWith("!!") : rec.startsWith("??") || (withIgnored && rec.startsWith("!!")));
63
+ const files = [];
64
+ let renameSource = false; // a rename or copy record is followed by its source path
65
+ for (const rec of st.stdout.split("\0")) {
66
+ if (renameSource) { renameSource = false; continue; }
67
+ if (rec.length < 4) continue;
68
+ if (wanted(rec)) files.push(rec.slice(3));
69
+ renameSource = "RC".includes(rec[0]);
70
+ }
71
+ return snapshotExistingPaths(root, files, top.stdout.trim(), command);
72
+ }
73
+
50
74
  async function snapshotGitRepo(root, cwd, command, kind) {
51
75
  if (!cwd) return { snapshots: [], skipped: "no cwd" };
52
76
  try {
@@ -86,11 +110,9 @@ export async function handleHookPayload(payload, { root, stdin }) {
86
110
  await assertOwnedByCurrentUser(root);
87
111
  let snapshots = [];
88
112
  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
- }
113
+ if (verdict.bundle) ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
114
+ snapshots.push(...(await snapshotExistingPaths(root, verdict.paths, data.cwd, command)));
115
+ if (verdict.worktree) snapshots.push(...(await snapshotDirtyFiles(root, data.cwd, command, verdict.worktree)));
94
116
  if (snapshots.length) {
95
117
  const m = await loadManifest(root);
96
118
  for (const s of snapshots) m.entries.push(s);
@@ -106,7 +128,7 @@ export async function handleHookPayload(payload, { root, stdin }) {
106
128
  if (snapshots.length) {
107
129
  return reply(
108
130
  "allow",
109
- `ai-trash: snapshotted ${snapshots.length} ${verdict.kind.startsWith("git-") ? "git bundle" : "path(s)"} before ${verdict.kind}`
131
+ `ai-trash: snapshotted ${snapshots.length} ${snapshots.some((s) => s.type === "git-bundle") ? "item(s), git bundle included," : "path(s)"} before ${verdict.kind}`
110
132
  );
111
133
  }
112
134
  return reply(
@@ -26,6 +26,15 @@ const seaTransformPlugin = {
26
26
  let contents = await fs.readFile(args.path, "utf8");
27
27
  let modified = false;
28
28
 
29
+ // KJC-TSK-0915 (ADR 0014): the Sentinel's guard modules are read as TEXT at
30
+ // load time, to be copied into a project's harness. A single-file bundle has
31
+ // no sibling files, so the text is inlined here, before import.meta.url goes.
32
+ const SENTINEL_READ = /readFileSync\(new URL\("(\.\/sentinel\/[\w-]+\.mjs)", import\.meta\.url\), "utf8"\)/g;
33
+ if (SENTINEL_READ.test(contents)) {
34
+ contents = contents.replace(SENTINEL_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
35
+ modified = true;
36
+ }
37
+
29
38
  // Replace import.meta.dirname with __dirname
30
39
  if (contents.includes("import.meta.dirname")) {
31
40
  contents = contents.replaceAll("import.meta.dirname", "__dirname");
@@ -36,7 +36,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
36
36
  const blocks = blocksRes.exitCode === 0
37
37
  ? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
38
38
  : [];
39
- const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files, env: {} }).mode !== "pass").length;
39
+ const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files }).mode !== "pass").length;
40
40
 
41
41
  const verdicts = recentVerdicts(projectDir, sample);
42
42
  const stamped = verdicts.filter((v) => v.workspace);
@@ -34,7 +34,9 @@ async function policyRangeCheck(projectDir) {
34
34
  // protects the supervisor. `kj review` and `kj policy check` already lift
35
35
  // what the sealed provenance backs; without this the release check blocked
36
36
  // every version cut after a human `kj harden --commit`, by its own seal.
37
- const sup = liftSealedSupervisorViolations({ projectDir, violations: raw });
37
+ // KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
38
+ // the machine-local guards were verified when the seal was reviewed.
39
+ const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
38
40
  const violations = sup.violations;
39
41
  const hard = violations.filter((v) => v.enforcement === "deny");
40
42
  const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
@@ -30,7 +30,7 @@ import { envInstallCommand, briefCommand } from "../commands/env.js";
30
30
  import { runReleaseCheck } from "../checks/release-check.js";
31
31
  import { agentRunCommand } from "../commands/agent-run.js";
32
32
  import { reportIssueCommand } from "../commands/report-issue.js";
33
- import { huCommand } from "../commands/hu.js";
33
+ import { huCommand, HU_STATUSES } from "../commands/hu.js";
34
34
  import { worktreeCommand } from "../commands/worktree.js";
35
35
  import { addAdr, listAdrs } from "../environment/adr.js";
36
36
  import { formatAdvancedIndex } from "../commands/advanced.js";
@@ -271,6 +271,8 @@ export function registerMeta(program, { pkgVersion }) {
271
271
  });
272
272
  });
273
273
  hu.command("move <id> <status>")
274
+ // KJC-BUG-0257: the valid states, said before the call fails.
275
+ .description(`Move a card; <status> is one of: ${HU_STATUSES.join(", ")}`)
274
276
  .option("--json", "Machine-readable output")
275
277
  .action(async (id, status, flags) => {
276
278
  await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
@@ -201,7 +201,8 @@ export function registerPipeline(program, { pkgVersion }) {
201
201
  program
202
202
  .command("solomon")
203
203
  .description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
204
- .requiredOption("--position <text>", "Why the brain disagrees with the reviewer")
204
+ .option("--position <text>", "Why the brain disagrees with the reviewer")
205
+ .option("--position-file <path>", "Read the position from a file (no quoting to get right)")
205
206
  .option("--range <range>", "Arbitrate a git range instead of the staged diff")
206
207
  .action(async (flags) => {
207
208
  await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
@@ -14,7 +14,7 @@ import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
14
14
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
15
15
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
16
16
  import { getKarajanHome } from "../utils/paths.js";
17
- import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
17
+ import { countProjectChunks, countProjectSources, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
18
18
  import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
19
19
 
20
20
  // KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
@@ -153,6 +153,11 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
153
153
  if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
154
154
  return [];
155
155
  }
156
+ // KJC-BUG-0258: chunks of plans and briefs but none of the project's own files
157
+ // answer nothing about the code; said now, not when kj review --staged blocks.
158
+ if (!library && project && countProjectSources(db, project, config?.projectDir || process.cwd()) === 0) {
159
+ logger.warn("[rag] this project's index holds none of its own files (only plans or briefs): run kj rag index --with-sources");
160
+ }
156
161
  const mode = flags.mode || "hybrid";
157
162
  const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
158
163
  // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
@@ -234,6 +239,19 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
234
239
  }
235
240
  }
236
241
 
242
+ /**
243
+ * KJC-BUG-0255: what `kj rag migrate` keeps. The project's own files pass the
244
+ * indexer's criterion of today; sources outside the tree (plans, onboarding)
245
+ * are kept as they are.
246
+ */
247
+ export function migrateKeep(projectDir, config) {
248
+ const exclude = ragExclude(config);
249
+ return (source) => {
250
+ const rel = relative(projectDir, source);
251
+ return rel.startsWith("..") || isAbsolute(rel) || indexableReason(rel, source, { exclude, projectDir }) === null;
252
+ };
253
+ }
254
+
237
255
  /**
238
256
  * KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
239
257
  * proyecto desde la base global a su propio indice, con sus embeddings, sin
@@ -241,11 +259,13 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
241
259
  */
242
260
  export async function ragMigrateCommand({ config, flags = {} }) {
243
261
  const projectDir = config?.projectDir || process.cwd();
262
+ const keep = migrateKeep(projectDir, config);
244
263
  const res = migrateProjectIndex({
245
264
  slug: projectSlug(projectDir),
246
265
  legacyPath: join(getKarajanHome(), "rag.db"),
247
266
  targetPath: projectDbPath(projectDir),
248
267
  dim: config?.rag?.embedder?.dim || 768,
268
+ keep,
249
269
  });
250
270
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
251
271
  else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
@@ -42,7 +42,6 @@ async function enforceCardFirst({ config, projectDir }) {
42
42
  // KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
43
43
  // branch-ref alone must never read as a tracker-verified pass.
44
44
  if (card.note) console.log(`⚠ card-first: ${card.note}`);
45
- if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
46
45
  if (!card.ok) {
47
46
  console.log(`✗ card-first gate: ${card.reason}`);
48
47
  process.exitCode = 1;
@@ -139,6 +138,14 @@ function printVerdict(record) {
139
138
  */
140
139
  export async function solomonCommand({ config, logger = null, flags = {} }) {
141
140
  const projectDir = config?.projectDir || process.cwd();
141
+ // KJC-BUG-0260: a position is prose with blanks and slashes; from a file it
142
+ // needs no quoting the lane guard has to tell apart from a path.
143
+ const position = flags.positionFile ? readFileSync(flags.positionFile, "utf8").trim() : flags.position;
144
+ if (!position) {
145
+ console.log("✗ kj solomon: give the brain's position with --position <text> or --position-file <path>");
146
+ process.exitCode = 1;
147
+ return { ruling: "reject", reasoning: "no position given" };
148
+ }
142
149
  const diff = await rawDiff(flags.range);
143
150
  // KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
144
151
  // arbitrable — solomon se niega antes de gastar un token. Y una policy
@@ -162,7 +169,7 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
162
169
  process.exitCode = 1;
163
170
  return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
164
171
  }
165
- const res = await runSolomonArbitration({ diff, position: flags.position, config, logger, projectDir });
172
+ const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
166
173
  if (res.ruling === "approve") {
167
174
  console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
168
175
  } else {
@@ -256,10 +263,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
256
263
  // partitioning is a decision, and it must never split code from its tests.
257
264
  const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
258
265
  const sizePolicy = config?.method_gates?.pr_size || "warn";
259
- if (process.env.KJ_ALLOW_LARGE_PR === "1") {
260
- console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
261
- } else if (sizePolicy === "block") {
262
- const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
266
+ // ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
267
+ if (sizePolicy === "block") {
268
+ const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
263
269
  console.log(`✗ pr-size gate: ${reason}`);
264
270
  process.exitCode = 1;
265
271
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
@@ -269,7 +275,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
269
275
  }
270
276
  }
271
277
  // KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
272
- // Denylist data rejects (KJ_ALLOW_PII=1 is the named escape); generic PII
278
+ // Denylist data rejects with no escape (ADR 0015: false positives go in the allow list); generic PII
273
279
  // warns (privacy.generic: "block" hardens). Added lines only — deletions
274
280
  // don't publish. In the SEA binary the privacy module is stubbed and
275
281
  // throws: the gate degrades with a note instead of crashing.
@@ -288,25 +294,19 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
288
294
  for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
289
295
  const hardened = config?.privacy?.generic === "block" && warns.length > 0;
290
296
  if (blocks.length > 0 || hardened) {
291
- if (process.env.KJ_ALLOW_PII === "1") {
292
- console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
293
- } else {
294
- for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
295
- const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
296
- console.log(`✗ privacy gate: ${reason}`);
297
- process.exitCode = 1;
298
- return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
299
- }
297
+ for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
298
+ const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (a confirmed false positive goes in the allow list of ~/.karajan/privacy.yml)`;
299
+ console.log(`✗ privacy gate: ${reason}`);
300
+ process.exitCode = 1;
301
+ return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
300
302
  }
301
303
  } catch (err) {
302
304
  // Known degradation: the SEA stub throws its install-from-npm message.
303
305
  // Anything else fails CLOSED — a silent skip would defeat the guarantee.
304
306
  if (/standalone binary/i.test(err?.message || "")) {
305
307
  console.log("⚠ privacy gate unavailable in this build — skipping");
306
- } else if (process.env.KJ_ALLOW_PII === "1") {
307
- console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
308
308
  } else {
309
- const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked (KJ_ALLOW_PII=1 to override consciously)`;
309
+ const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked`;
310
310
  console.log(`✗ ${reason}`);
311
311
  process.exitCode = 1;
312
312
  return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
@@ -330,9 +330,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
330
330
 
331
331
  // KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
332
332
  // --staged Y en --check — el pre-commit hereda los dientes sin regenerar
333
- // hooks. enforcement=warn avisa; deny cierra salvo excepción probatoria
334
- // (KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, registrada con identidad y hash
335
- // del diff); class=security cierra sin escape y sin arbitraje.
333
+ // hooks. enforcement=warn avisa; deny cierra sin excepción por diff (ADR
334
+ // 0015); class=security cierra además sin arbitraje.
336
335
  // KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
337
336
  // del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
338
337
  // regla en dos lineas seguidas del mismo comando.
@@ -3,8 +3,9 @@
3
3
  * Field case 2026-08-02: text rules were not enough — the environment must
4
4
  * impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
5
5
  * it into the project's `.claude/settings.json` (merged, never clobbered):
6
- * Write over an existing file blocks ("use Edit", KJ_ALLOW_WRITE=1 escapes);
7
- * Bash that reserializes whole JSON files blocks (KJ_ALLOW_REWRITE=1).
6
+ * Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
7
+ * Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
8
+ * replaced the JSON-reserialization guard that lived here.
8
9
  * Claude-only v1 — the abstraction arrives with the second host that
9
10
  * supports tool hooks.
10
11
  */
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
19
20
  // so a gate bug never bricks the session.
20
21
  import console from "node:console";
21
22
  import process from "node:process";
22
- import { existsSync, realpathSync } from "node:fs";
23
- import { dirname, join, relative, resolve } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
- // El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
23
+ import { existsSync } from "node:fs";
27
24
  let raw = "";
28
25
  process.stdin.on("data", (d) => { raw += d; });
29
26
  process.stdin.on("end", () => {
30
27
  try {
31
28
  const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
32
- if (tool === "Write" && process.env.KJ_ALLOW_WRITE !== "1") {
33
- if (input.file_path && existsSync(input.file_path)) {
34
- console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes (KJ_ALLOW_WRITE=1 to override consciously).");
35
- process.exit(2);
36
- }
37
- }
38
- if (tool === "Bash") {
39
- const cmd = String(input.command || "");
40
- // KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
41
- // resto de guardias. Mirando solo process.env el agente no tenia salida:
42
- // un export dentro del comando jamas llega a este proceso.
43
- // Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
44
- // aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
45
- // un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
46
- const toks = cmd.trim().split(/\\s+/);
47
- let i = 0;
48
- let named = false;
49
- while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
50
- if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
51
- i += 1;
52
- }
53
- // Una asignacion delante solo alcanza a SU comando, asi que el escape del
54
- // comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
55
- // o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
56
- // que va entre comillas no encadena nada, asi que no cuenta.
57
- // Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
58
- // hereda la asignacion (catch de la review). Mismo criterio que el resto
59
- // de guardias del Sentinel.
60
- // Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
61
- // encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
62
- const noSingle = cmd.replaceAll(/'[^']*'/g, "");
63
- const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
64
- const bare = noSingle.replaceAll(/"[^"]*"/g, "");
65
- const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
66
- const cmdEscape = named && simple && i < toks.length;
67
- const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
68
- // Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
69
- if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
70
- const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
71
- // KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
72
- // temporal del scratchpad no es asunto de este guard, y bloquearlo
73
- // ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
74
- const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
75
- // Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
76
- // fuera del arbol. Una relativa depende del cwd del shell, que este hook
77
- // no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
78
- // dentro del repo (catch de la review), asi que se sigue vigilando.
79
- // Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
80
- // (catch de la review). Se resuelve el padre real antes de clasificar, y
81
- // si no se puede resolver, se vigila.
82
- // Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
83
- // <repo>/..config.json esta dentro (catch de la review).
84
- const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
85
- const outsideForSure = (t) => {
86
- if (!t.startsWith("/")) return false;
87
- const abs = resolve(t);
88
- try {
89
- const root = realpathSync(ROOT);
90
- // El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
91
- // asi que se resuelve entero cuando existe; si aun no existe, manda su
92
- // directorio real.
93
- if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
94
- const parts = abs.split("/");
95
- const name = parts.pop();
96
- return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
97
- } catch {
98
- return false;
99
- }
100
- };
101
- // Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
102
- // deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
103
- // de la review). Ante la duda se vigila.
104
- const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
105
- const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
106
- if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
107
- console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
108
- process.exit(2);
109
- }
29
+ if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
30
+ console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
31
+ process.exit(2);
110
32
  }
111
33
  } catch { /* fail open */ }
112
34
  process.exit(0);
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
62
62
  return [
63
63
  "# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
64
64
  "# the bootstrap commit is exempt, there is no commit to branch from yet.",
65
- 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
65
+ "# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
66
+ "if git rev-parse --verify HEAD >/dev/null 2>&1; then",
66
67
  ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
67
68
  ` if [ "$current_branch" = "${baseBranch}" ]; then`,
68
- ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
69
+ ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
69
70
  " fi",
70
71
  "fi",
71
72
  ];
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
78
79
  function identityGuard(hook) {
79
80
  const head = [
80
81
  "# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
81
- 'if [ "$KJ_ALLOW_IDENTITY" != "1" ] && [ -f .karajan/identity.local.yml ]; then',
82
+ "if [ -f .karajan/identity.local.yml ]; then",
82
83
  ];
83
84
  const tail = [
84
85
  "elif [ ! -f .karajan/identity.local.yml ]; then",
@@ -92,7 +93,7 @@ function identityGuard(hook) {
92
93
  " kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
93
94
  " kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
94
95
  ' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
95
- ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show; KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
96
+ ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
96
97
  " fi",
97
98
  ...tail,
98
99
  ];
@@ -103,7 +104,7 @@ function identityGuard(hook) {
103
104
  ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
104
105
  " kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
105
106
  ' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
106
- ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh (KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
107
+ ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
107
108
  " fi",
108
109
  ...tail,
109
110
  ];
@@ -0,0 +1,106 @@
1
+ // The Sentinel's bash-write guard (KJC-BUG-0237, moved here by KJC-TSK-0919,
2
+ // ADR 0014). Inside the repo, files are written through Edit/Write only, the path
3
+ // every gate guards. Copied byte for byte into .karajan/harness; `root` injected.
4
+ // The stated limit: a program you run can write files; only a sandbox stops that.
5
+ import { existsSync, realpathSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+ import { headIndex, shellSegments } from "./sentinel-shell.mjs";
9
+
10
+ const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
11
+ const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
12
+ // touch/truncate -s SIZE, -r REF, -d DATE, -t STAMP: the value is not a file it writes.
13
+ const VALUED = ["-s", "--size", "-r", "--reference", "-d", "--date", "-t"];
14
+
15
+ /** Redirection targets: >&N and >&- duplicate or close a descriptor; >& FILE and >&FILE write FILE. */
16
+ const redirections = (words) => {
17
+ const out = [];
18
+ words.forEach((w, k) => {
19
+ const m = /^\d*(>>|>[|]|>)(.*)$/.exec(w);
20
+ if (!m) return;
21
+ const dup = m[2].startsWith("&") ? m[2].slice(1) : null;
22
+ if (dup === null) out.push(m[2] || words[k + 1]);
23
+ else if (dup === "") out.push(words[k + 1]);
24
+ else if (!/^\d*-?$/.test(dup)) out.push(dup);
25
+ });
26
+ return out;
27
+ };
28
+
29
+ /** Destination of cp/mv/install/ln: -t DIR / --target-directory[=]DIR, else the last plain word. */
30
+ const destination = (rest, plain) => {
31
+ const t = rest.findIndex((w) => w === "-t" || w === "--target-directory");
32
+ const tEq = rest.find((w) => w.startsWith("--target-directory="));
33
+ if (t >= 0) return [rest[t + 1]];
34
+ if (tEq) return [tEq.slice(19)];
35
+ return plain.length >= 2 ? [plain.at(-1)] : [];
36
+ };
37
+
38
+ /** Files sed/perl -i edit: the script is the word after -e/-f, else the first plain word; a lone plain word is the file. */
39
+ const inPlace = (rest, plain) => {
40
+ if (!rest.some((w) => w.startsWith("--in-place") || /^-[a-zA-Z0-9]*i/.test(w))) return [];
41
+ const script = rest.findIndex((w) => ["-e", "-f", "--expression"].includes(w));
42
+ if (script >= 0) return plain.filter((w) => w !== rest[script + 1]);
43
+ return plain.length === 1 ? plain : plain.slice(1);
44
+ };
45
+
46
+ /**
47
+ * The files a simple command (a word list) writes from the shell. "$(...)"
48
+ * marks a target that only exists at run time: unknowable, so writesRepo denies it.
49
+ * @param {string[]} words
50
+ * @returns {string[]}
51
+ */
52
+ export const shellWrites = (words) => {
53
+ const out = redirections(words);
54
+ const i = headIndex(words, [...WRITES, ...RUNNERS]);
55
+ const head = (words[i] || "").split("/").at(-1);
56
+ // xargs / find -exec run a writer on targets that arrive at run time.
57
+ if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
58
+ // Arguments without redirections (< << <<< > >> and a detached operand).
59
+ const rest = [];
60
+ for (let k = i + 1; k < words.length; k++) {
61
+ if (!/^\d*[<>]/.test(words[k])) rest.push(words[k]);
62
+ else if (/^\d*(<{1,3}|>>?|>[|&])$/.test(words[k])) k++;
63
+ }
64
+ // Operands: words that are not options, and EVERY word after "--" (touch -- -file).
65
+ const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
66
+ const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
67
+ if (head === "tee") out.push(...plain);
68
+ if (head === "dd") out.push(...rest.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
69
+ // By position, not value (touch -d today today writes "today").
70
+ if (head === "touch" || head === "truncate") out.push(...rest.filter((w, k) => k > cut || (k < cut && !w.startsWith("-") && !VALUED.includes(rest[k - 1]))));
71
+ // sh -c / eval run a script of their own: its writes are this command's writes.
72
+ if (["sh", "bash", "zsh", "dash"].includes(head) && rest.includes("-c")) for (const seg of shellSegments(rest[rest.indexOf("-c") + 1] || "")) out.push(...shellWrites(seg));
73
+ if (head === "eval") for (const seg of shellSegments(rest.join(" "))) out.push(...shellWrites(seg));
74
+ // An inline script (node -e, python -c...) that names a write API.
75
+ const inline = rest[rest.findIndex((w) => ["-e", "-c", "--eval", "-p", "-r"].includes(w)) + 1] || "";
76
+ if (/^(node|deno|bun|python[\d.]*|ruby|perl|php)$/.test(head) && /write|append|open|copy|rename|truncate|unlink|mkdir|symlink/i.test(inline)) out.push(`$(${head})`);
77
+ if (["cp", "mv", "install", "ln"].includes(head)) out.push(...destination(rest, plain));
78
+ if (head === "sed" || head === "perl") out.push(...inPlace(rest, plain));
79
+ return out.filter(Boolean);
80
+ };
81
+
82
+ /**
83
+ * Inside the repo, or unknowable ($VAR, backtick, ~user): both deny. /dev/* and
84
+ * descriptor duplication (&1) are outside; ~/ is the home, where the repo may live.
85
+ * Real paths: a link outside (/tmp/link -> repo) points in.
86
+ * @param {string} t
87
+ * @param {string} root
88
+ */
89
+ export const writesRepo = (t, root) => {
90
+ if (t.includes("$") || t.includes("`")) return true;
91
+ if (t.startsWith("&") || t.startsWith("/dev/")) return false;
92
+ if (t.startsWith("~") && t !== "~" && !t.startsWith("~/")) return true;
93
+ let a = resolve(root, t.startsWith("~") ? homedir() + t.slice(1) : t);
94
+ let tail = "";
95
+ while (!existsSync(a) && dirname(a) !== a) {
96
+ tail = join(a.slice(dirname(a).length + 1), tail);
97
+ a = dirname(a);
98
+ }
99
+ let rel;
100
+ try {
101
+ rel = relative(realpathSync(root), join(realpathSync(a), tail));
102
+ } catch {
103
+ return true;
104
+ }
105
+ return !rel.startsWith("..") && !rel.startsWith("/");
106
+ };