karajan-code 4.33.0 → 4.35.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.
@@ -11,6 +11,8 @@
11
11
  * code, and then it says so instead of staying quiet.
12
12
  */
13
13
  import { resolveRole } from "../config/role-resolver.js";
14
+ import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
15
+ import { readIdentity } from "../identity/store.js";
14
16
 
15
17
  /** Roles worth announcing: who writes, who reviews, who arbitrates. */
16
18
  export const PANEL_ROLES = ["coder", "reviewer", "solomon"];
@@ -42,6 +44,37 @@ export function panelLine(config) {
42
44
  ].join("\n");
43
45
  }
44
46
 
47
+ /**
48
+ * KJC-TSK-0868: the host writing the code while another coder is declared is
49
+ * not an offence to block, it is a fact to record. Announcing at session start
50
+ * is not enough: the user chose who writes and finds out it did not happen by
51
+ * accident, one turn at a time.
52
+ *
53
+ * @returns {{coder: string, host: string}|null} null when there is nothing to
54
+ * record: no coder declared, no host detected, or the host IS the coder.
55
+ */
56
+ export function panelDeviation(config, host) {
57
+ const { coder } = resolvePanel(config);
58
+ if (!coder || !host || coder === host) return null;
59
+ return { coder, host };
60
+ }
61
+
62
+ /**
63
+ * KJC-TSK-0873: the log holds both sides. A deviation alone cannot say whether
64
+ * the panel is honoured almost always or almost never, and those are different
65
+ * situations. The compliance seat is written where the declared coder is
66
+ * actually invoked (`kj code`); the deviation one when the turn ends.
67
+ */
68
+ export function sealPanel({ projectDir, coder, host = null, honoured = false }) {
69
+ const who = readIdentity(projectDir);
70
+ return recordGateDecision(projectDir, {
71
+ decision: honoured ? "ok" : "exempt", chokepoint: "panel", escape: null, tool: null,
72
+ coder, host: honoured ? coder : host,
73
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
74
+ policy_hash: policyFileHash(projectDir),
75
+ });
76
+ }
77
+
45
78
  /** Human-facing one-liner for `kj check` and friends. */
46
79
  export function panelSummary(config) {
47
80
  const { coder, reviewer, solomon } = resolvePanel(config);
@@ -12,7 +12,7 @@ const DESTRUCTIVE_PATTERNS = [
12
12
  const CREDENTIAL_PATTERNS = [
13
13
  { id: "aws-key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical", message: "AWS access key exposed" },
14
14
  { id: "private-key", pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, severity: "critical", message: "Private key exposed" },
15
- { id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected. Use .env file + process.env instead" },
15
+ { id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected. Move it to the environment or a secrets manager" },
16
16
  { id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, severity: "critical", message: "GitHub token exposed" },
17
17
  { id: "npm-token", pattern: /npm_[A-Za-z0-9]{36,}/, severity: "critical", message: "npm token exposed" },
18
18
  { id: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/, severity: "critical", message: "OpenAI API key exposed" },
@@ -24,7 +24,7 @@ const CREDENTIAL_PATTERNS = [
24
24
  { id: "slack-token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/, severity: "critical", message: "Slack token exposed" },
25
25
  { id: "jwt-secret", pattern: /jwt[_-]?secret\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "JWT secret hardcoded. Use .env file" },
26
26
  { id: "database-url", pattern: /(mongodb|postgres|mysql|redis):\/\/[^"'\s]+:[^"'\s]+@/i, severity: "critical", message: "Database URL with credentials exposed. Use .env file" },
27
- { id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in variable. Use process.env or .env file" },
27
+ { id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in an assignment. Move it to the environment or a secrets manager" },
28
28
  ];
29
29
 
30
30
  // Default protected files (block if these appear in added/modified lines)
@@ -115,6 +115,49 @@ export function checkProtectedFiles(diff, protectedFiles) {
115
115
  return violations;
116
116
  }
117
117
 
118
+ /**
119
+ * KJC-BUG-0215 (issue #1733): estas dos reglas acusan por el NOMBRE del
120
+ * identificador, no por la forma del valor, asi que un prefijo de cache llamado
121
+ * EXPERIMENTS_HANDOFF_TOKENS_NAMESPACE bloqueo una ejecucion entera con
122
+ * severidad critica y sin salida. Las demas reglas miran el valor (AKIA…,
123
+ * sk_live_…) y esas aciertan.
124
+ *
125
+ * El arreglo NO es adivinar: la review tumbo tres heuristicas seguidas y con
126
+ * razon, porque `const dbPassword = "db-password"` tambien tiene pinta inocente
127
+ * y es una credencial. Lo que faltaba era CAUCE: que el proyecto pueda declarar
128
+ * la excepcion en su configuracion versionada, donde se revisa en la PR como
129
+ * cualquier otra linea, y que el guard diga como hacerlo cuando bloquea.
130
+ */
131
+ const NAME_BASED_RULES = new Set(["hardcoded-key-assignment", "generic-secret"]);
132
+
133
+ const HOW_TO_DECLARE = "If it is not a secret, declare it in guards.output.non_secret_assignments as NAME or path/to/file:NAME";
134
+
135
+ /** El identificador al que se asigna, cuando la linea deja leerlo. */
136
+ const assignedName = (content) => /([A-Za-z_$][\w$]*)\s*[:=]\s*["']/.exec(content)?.[1] ?? null;
137
+
138
+ /** Una entrada declarada se lee como `NAME` o `path/to/file:NAME`. */
139
+ const splitEntry = (entry) => {
140
+ const cut = entry.lastIndexOf(":");
141
+ return cut === -1 ? { path: null, name: entry } : { path: entry.slice(0, cut), name: entry.slice(cut + 1) };
142
+ };
143
+
144
+ /**
145
+ * Excepcion DECLARADA, no inferida: el proyecto afirma por escrito que esta
146
+ * asignacion no guarda un secreto. Sin `file` la declaracion vale para todo el
147
+ * repositorio; con el, solo para ese fichero, que es lo estrecho y preferible.
148
+ */
149
+ export function declaredNonSecret(configGuards, file, content) {
150
+ const declared = configGuards?.output?.non_secret_assignments;
151
+ if (!Array.isArray(declared) || declared.length === 0) return false;
152
+ const name = assignedName(content);
153
+ if (!name) return false;
154
+ return declared.some(entry => {
155
+ if (typeof entry !== "string") return false;
156
+ const target = splitEntry(entry.trim());
157
+ return target.name === name && (target.path === null || target.path === file);
158
+ });
159
+ }
160
+
118
161
  /**
119
162
  * Scan a diff for pattern violations.
120
163
  * Returns { pass: boolean, violations: Array<{id, severity, file, line, message, matchedContent}> }
@@ -133,8 +176,17 @@ export function scanDiff(diff, config = {}) {
133
176
  // Check patterns against added lines
134
177
  for (const { file, line, content } of addedLines) {
135
178
  for (const { id, pattern, severity, message } of patterns) {
136
- if (pattern.test(content)) {
137
- violations.push({ id, severity, file, line, message, matchedContent: content.trim().slice(0, 200) });
179
+ if (!pattern.test(content)) continue;
180
+ const matchedContent = content.trim().slice(0, 200);
181
+ // KJC-BUG-0215: una regla que acusa por el nombre necesita un cauce
182
+ // declarado para el falso positivo, y decirlo cuando bloquea.
183
+ if (!NAME_BASED_RULES.has(id)) {
184
+ violations.push({ id, severity, file, line, message, matchedContent });
185
+ } else if (declaredNonSecret(configGuards, file, content)) {
186
+ violations.push({ id, severity: "warning", file, line, matchedContent,
187
+ message: `${message}. Declared as non-secret in guards.output.non_secret_assignments` });
188
+ } else {
189
+ violations.push({ id, severity, file, line, matchedContent, message: `${message}. ${HOW_TO_DECLARE}` });
138
190
  }
139
191
  }
140
192
  }
@@ -28,6 +28,8 @@ export function stripDevHooksBlock(source) {
28
28
  return { content: `${before}${joiner}${after}`, migrated: true };
29
29
  }
30
30
 
31
+ const countLines = (text) => String(text || "").split("\n").filter((l) => l.trim()).length;
32
+
31
33
  export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
32
34
  // KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
33
35
  // be the rules of THIS project's language. kj harden already detects it for
@@ -45,8 +47,12 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
45
47
  body,
46
48
  style: "html",
47
49
  });
50
+ // KJC-BUG-0206: harden removing rules is a fact worth saying. It silently
51
+ // dropped six lines from this repo's own files because the detected language
52
+ // had no block, and "updated" read like an improvement.
53
+ const shrank = countLines(content) < countLines(raw);
48
54
  if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
49
- results.push({ file, action: migrated ? "migrated" : action });
55
+ results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true } : {}) });
50
56
  }
51
57
  return { dryRun, guidelines: results };
52
58
  }
@@ -76,9 +76,18 @@ export const LANGUAGE_GUIDELINES = {
76
76
  ],
77
77
  };
78
78
 
79
+ /**
80
+ * Languages that share another's block (KJC-BUG-0206). TypeScript is the same
81
+ * ecosystem as JavaScript, and the block already says so in its heading; but the
82
+ * stack detector reports `typescript` for a repo with a tsconfig, so the lookup
83
+ * missed and harden ERASED the rules a project already had.
84
+ */
85
+ const ALIASES = { typescript: "javascript" };
86
+
79
87
  /** The guidelines body for a language (unknown or absent ⇒ the core alone). */
80
88
  export function guidelinesBody(language = null) {
81
- const extra = LANGUAGE_GUIDELINES[String(language || "").toLowerCase()] ?? [];
89
+ const key = String(language || "").toLowerCase();
90
+ const extra = LANGUAGE_GUIDELINES[ALIASES[key] ?? key] ?? [];
82
91
  return [...CORE, ...extra].join("\n");
83
92
  }
84
93
 
@@ -44,6 +44,12 @@ process.stdin.on("end", () => {
44
44
  `;
45
45
 
46
46
  /** Write a managed hook script under .karajan/harness/ and return its path. */
47
+ /**
48
+ * KJC-BUG-0211: el cuerpo canonico de los guardias que instala ESTE modulo,
49
+ * para que quien verifica un sello pueda RECOMPUTAR en vez de fiarse del hash.
50
+ */
51
+ export const HARNESS_BODIES = { "pretooluse.mjs": SCRIPT_BODY };
52
+
47
53
  export function writeHarnessScript(projectDir, name, body) {
48
54
  const dir = join(projectDir, ".karajan", "harness");
49
55
  mkdirSync(dir, { recursive: true });
@@ -10,9 +10,10 @@
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
+ import { createHash } from "node:crypto";
13
14
  import { join } from "node:path";
14
15
  import { CARD_REF_RE } from "../review/card-first.js";
15
- import { mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
16
+ import { HARNESS_BODIES, mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
16
17
 
17
18
  /** The harness lives at the PROJECT root — resolve it even from a subdir. */
18
19
  export function resolveSentinelRoot(dir = process.cwd()) {
@@ -134,7 +135,7 @@ import process from "node:process";
134
135
  import { relative } from "node:path";
135
136
  import { spawnSync } from "node:child_process";
136
137
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
137
- const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG"];
138
+ const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
138
139
  let raw = "";
139
140
  process.stdin.on("data", (d) => { raw += d; });
140
141
  process.stdin.on("end", () => {
@@ -364,6 +365,18 @@ process.stdin.on("end", () => {
364
365
  if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
365
366
  process.exit(0);
366
367
  }
368
+ // KJC-TSK-0868: si el anfitrion ha escrito codigo teniendo otro coder
369
+ // declarado, eso no se bloquea, se REGISTRA: el usuario eligio quien
370
+ // escribe y merece enterarse por el decision log, no por casualidad. Una
371
+ // vez por sesion, y si kj no responde se reintenta en el turno siguiente.
372
+ if (!s.panel_sealed && (s.edited_sources || []).length > 0) {
373
+ const p = spawnSync("kj", ["sentinel", "panel-check", "--session", sid], { cwd: ROOT, encoding: "utf8" });
374
+ if (!p.error && p.status === 0) {
375
+ s.panel_sealed = true;
376
+ save(state);
377
+ if ((p.stdout || "").trim()) console.error(p.stdout.trim());
378
+ }
379
+ }
367
380
  const v = violations(s, branchOf());
368
381
  if (claimsBlock) v.push(claimsBlock);
369
382
  if (!v.length) {
@@ -406,9 +419,19 @@ import { dirname, join, relative, resolve } from "node:path";
406
419
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
407
420
  import { spawnSync } from "node:child_process";
408
421
  import { homedir } from "node:os";
422
+ import { fileURLToPath } from "node:url";
409
423
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
410
424
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
411
- const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
425
+ // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
426
+ // (firebase --account a@b --project p deploy --only hosting:main), asi que la
427
+ // adyacencia dejaba pasar sin mirar justo el despliegue que el gate vigila.
428
+ // Las palabras del verbo tienen que aparecer en orden, no pegadas.
429
+ const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
430
+ // Cada palabra se lee por su basename: /usr/bin/npm publish es la misma
431
+ // publicacion que npm publish (catch de la review).
432
+ const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
433
+ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
434
+ const isPublish = (cmd) => { const got = wordsOf(cmd); return PUBLISH_VERBS.some((verb) => inOrder(verb, got)); };
412
435
  const PUSH = /\\bgit\\s+push\\b/;
413
436
  const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
414
437
  let raw = "";
@@ -849,7 +872,30 @@ process.stdin.on("end", () => {
849
872
  process.exit(2);
850
873
  }
851
874
  } else if (pres.status !== 0) {
852
- if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
875
+ // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
876
+ // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
877
+ // la sesion sin Edit, sin Write y sin Bash, o sea sin las herramientas
878
+ // para arreglar el fichero, y el remedio que sugeria (kj policy check)
879
+ // tampoco arrancaba. Se avisa fuerte y se deja pasar: el resto de este
880
+ // guard sigue vigente, que es lo que protege el metodo.
881
+ const broken = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.exec(String(pres.stderr || ""));
882
+ // Abrir el gate entero seria un bypass a la carta (romper un modulo y
883
+ // seguir sin policy), catch de la review. Se permite EXACTAMENTE una
884
+ // cosa: editar el fichero que rompe kj. Lo demas sigue denegado.
885
+ const at = broken ? (String(pres.stderr || "").split("\\n").map((l) => l.trim()).find((l) => /^[^ ]+\\.(m?js|cjs|ts):\\d+$/.test(l)) || "") : "";
886
+ // Node localiza los errores de un modulo ESM como file:///ruta.js:124,
887
+ // y esa forma no es una ruta de fichero (catch de la review).
888
+ const asPath = (p) => (p.startsWith("file:") ? fileURLToPath(p) : p);
889
+ const brokenFile = at ? asPath(at.slice(0, at.lastIndexOf(":"))) : "";
890
+ if (broken) {
891
+ const target = input.file_path || input.notebook_path || "";
892
+ const repairing = EDIT_TOOLS.includes(tool) && brokenFile && target && resolve(String(target)) === resolve(brokenFile);
893
+ if (repairing) console.error("karajan sentinel: kj no arranca (" + broken[0] + " en " + at + ") — la policy no se puede evaluar, asi que solo se permite arreglar ESE fichero. Arreglalo y vuelve." + doc("policy"));
894
+ else {
895
+ console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
896
+ process.exit(2);
897
+ }
898
+ } else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
853
899
  else {
854
900
  console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
855
901
  process.exit(2);
@@ -907,19 +953,40 @@ process.stdin.on("end", () => {
907
953
  const rs = load().sessions?.[sid] || {};
908
954
  const hits = rs.rag_hits || [];
909
955
  const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
910
- const fresh = !existsSync(String(file));
956
+ // KJC-BUG-0210: nuevo no es "no existe en disco". En cuanto la sesion
957
+ // lo crea, la siguiente edicion exigia una respuesta del RAG sobre un
958
+ // fichero que el indice NO puede tener. El hecho comprobable: un
959
+ // fichero que git no conoce no ha podido indexarse.
960
+ // Solo se pregunta a git en el camino lento, cuando el gate iba a
961
+ // denegar: exit 1 = git responde que no lo conoce; cualquier otro
962
+ // resultado se comporta como hasta ahora y bloquea.
963
+ const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
911
964
  // A path outside this tree is another lane's business (the lane guard
912
965
  // above rules on it) and the ledger only holds in-repo paths.
913
966
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
914
967
  // the gate asks for kj rag index instead of denying the impossible.
915
- const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0) || rs.rag_index_empty === true;
968
+ const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
916
969
  if (!covered) {
917
970
  if (escOn("KJ_ALLOW_NO_RAG")) {
918
971
  // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
919
972
  if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
920
973
  } else {
921
- console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
922
- process.exit(2);
974
+ // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
975
+ // sin adapter NO puede estar en el indice, asi que exigir una
976
+ // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
977
+ // dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
978
+ // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
979
+ // y sigue bloqueando, porque si no el gate desapareceria en
980
+ // cualquier proyecto sin indexar. Nada legible = se bloquea.
981
+ const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
982
+ let blind = null;
983
+ try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
984
+ if (blind) {
985
+ console.error("karajan sentinel: rag-first — " + blind + " — no se te exige consultar lo que el indice no puede tener; el gate lo anota y sigue" + doc("rag-first"));
986
+ } else {
987
+ console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
988
+ process.exit(2);
989
+ }
923
990
  }
924
991
  }
925
992
  }
@@ -930,15 +997,54 @@ process.stdin.on("end", () => {
930
997
  // and nothing advances (commit, new PR, another merge; push and the end of
931
998
  // the turn go through violations()) until the card is moved. The board is
932
999
  // true at all times or the method stops — never a rule in the agent's memory.
1000
+ // ENF-B (KJC-TSK-0854): el review es obligatorio, no una sugerencia. Una
1001
+ // bandera no puede apagar el gate de commit, y mover core.hooksPath lo
1002
+ // apaga entero. El --no-verify que kj harden --commit usa por dentro no
1003
+ // pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
1004
+ const words = wordsOf(cmd);
1005
+ // git acepta abreviaturas no ambiguas de las opciones largas, asi que
1006
+ // --no-ver salta el hook igual que --no-verify (catch de la review).
1007
+ const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
1008
+ const skipsVerify = (w) => shortWithN(w) || (w.length >= 5 && "--no-verify".startsWith(w));
1009
+ // Mover core.hooksPath apaga TODOS los hooks. git tiene varias puertas
1010
+ // para lo mismo (config, -c, --config-env, GIT_CONFIG_KEY_n) y no
1011
+ // distingue mayusculas en la llave, asi que lo que se vigila es la LLAVE
1012
+ // en cualquier posicion (dos catches de la review). Leerla no hace nada.
1013
+ // Nombrar la llave basta para denegar: exceptuar las lecturas invitaba a
1014
+ // colar una detras del cambio (git config core.hooksPath /x; cat algo),
1015
+ // catch de la review. Leerla tiene remedio barato, perderla no.
1016
+ // Se busca la llave DENTRO del token y no solo al principio: las formas
1017
+ // de inyeccion la empotran (GIT_CONFIG_KEY_0=core.hooksPath,
1018
+ // --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
1019
+ // depender de como parta las palabras el separador (catch de la review).
1020
+ const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1021
+ if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
1022
+ if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
1023
+ else {
1024
+ console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta, es una decision de tu usuario. (KJ_ALLOW_NO_VERIFY=1 = excepcion consciente, queda registrada)" + doc("escapes"));
1025
+ process.exit(2);
1026
+ }
1027
+ }
933
1028
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
934
1029
  const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
935
1030
  const mergeM = cmd.match(MERGE);
936
1031
  const pend = pendingMoves(load().sessions?.[sid]);
937
1032
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
938
- if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
939
- else {
940
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + pend.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
941
- process.exit(2);
1033
+ // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1034
+ // por su primera mitad. kj decide con un hecho comprobable (otra PR
1035
+ // abierta de la card); si kj no responde, se bloquea como siempre.
1036
+ const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
1037
+ let blocking = pend, carried = [];
1038
+ if (!bg.error && (bg.status === 0 || bg.status === 2)) {
1039
+ try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
1040
+ }
1041
+ for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1042
+ if (blocking.length > 0) {
1043
+ if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
1044
+ else {
1045
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
1046
+ process.exit(2);
1047
+ }
942
1048
  }
943
1049
  }
944
1050
  // (the pending entry itself is recorded by the PostToolUse hook, only once
@@ -956,13 +1062,24 @@ process.stdin.on("end", () => {
956
1062
  process.exit(2);
957
1063
  }
958
1064
  }
959
- if (PUBLISH.test(cmd)) {
1065
+ if (isPublish(cmd)) {
960
1066
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
961
- const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
1067
+ // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1068
+ // declara remedied_by no bloquea el comando que lo repara (desplegar
1069
+ // la landing era justo lo que el check pedia); publicar no se exime.
1070
+ const res = spawnSync("kj", ["release", "check", "--json", "--for-command", cmd], { cwd: ROOT, encoding: "utf8" });
962
1071
  if (res.error || res.status === null) process.exit(0);
1072
+ let parsed = null;
1073
+ try { parsed = JSON.parse(res.stdout); } catch { /* raw output */ }
1074
+ const lifted = (parsed && parsed.lifted) || [];
1075
+ if (res.status === 0 && lifted.length > 0) {
1076
+ // En voz alta: una exencion que nadie ve no se distingue de un gate que no mira.
1077
+ console.error("karajan sentinel: release check en rojo SOLO por lo que este comando repara (" + lifted.join(", ") + ") — pasa, y el resto sigue en verde" + doc("release"));
1078
+ process.exit(0);
1079
+ }
963
1080
  if (res.status !== 0) {
964
1081
  let items = "";
965
- try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1082
+ try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
966
1083
  console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)" + doc("release"));
967
1084
  process.exit(2);
968
1085
  }
@@ -1000,24 +1117,124 @@ const SCRIPT_BODIES = {
1000
1117
  * package — outside the project tree, beyond the session's tool reach —
1001
1118
  * because nothing under .karajan can vouch for itself.
1002
1119
  */
1003
- export function verifySentinelScripts({ projectDir } = {}) {
1004
- const dir = join(projectDir || resolveSentinelRoot(), ".karajan", "harness");
1120
+ const PROVENANCE_PATH = ".karajan/supervisor-provenance.json";
1121
+
1122
+ /**
1123
+ * KJC-BUG-0211: el cuerpo que kj escribiria para ese guardia, venga de las
1124
+ * plantillas del Sentinel o de las del harness. Quien verifica un sello
1125
+ * recomputa con esto; sin el, un sello que cubre los guardias (KJC-BUG-0197)
1126
+ * no se puede ni commitear, porque nadie sabe comprobarlos.
1127
+ */
1128
+ export const canonicalHarnessBody = (name) => SCRIPT_BODIES[name] ?? HARNESS_BODIES[name] ?? null;
1129
+
1130
+ /**
1131
+ * KJC-BUG-0197: what did a human actually seal, per guard?
1132
+ *
1133
+ * Read from **git**, never from the working tree. The provenance is tracked and
1134
+ * written only by `kj harden --commit` (a human act with four layers, ADR 0009),
1135
+ * so the committed copy is the external reference; the copy on disk is as
1136
+ * forgeable as the guard it would vouch for, and the review caught exactly that.
1137
+ * No git, no commit, no seal: then nothing is forgiven and the check fails
1138
+ * closed, which is the safe direction.
1139
+ *
1140
+ * The map is keyed BY PATH: a hash sealed for one file never vouches for
1141
+ * another (also the review's catch).
1142
+ *
1143
+ * @returns {Map<string, string>} relative path → sealed sha256
1144
+ */
1145
+ function sealedByPath(projectDir, gitShowFn) {
1146
+ try {
1147
+ const raw = gitShowFn
1148
+ ? gitShowFn(projectDir, PROVENANCE_PATH)
1149
+ : execFileSync("git", ["-C", projectDir, "show", `HEAD:${PROVENANCE_PATH}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1150
+ const files = JSON.parse(raw)?.files;
1151
+ const out = new Map();
1152
+ for (const entry of Array.isArray(files) ? files : []) {
1153
+ if (typeof entry?.file === "string" && typeof entry?.sha256 === "string") out.set(entry.file, entry.sha256);
1154
+ }
1155
+ return out;
1156
+ } catch {
1157
+ return new Map();
1158
+ }
1159
+ }
1160
+
1161
+ /**
1162
+ * @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
1163
+ * `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
1164
+ * what the hooks block on. `drift` is a sealed guard whose templates have
1165
+ * moved on, which `kj harden` applies and which is not a defect.
1166
+ */
1167
+ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, gitShowFn = null } = {}) {
1168
+ const root = projectDir || resolveSentinelRoot();
1169
+ const dir = join(root, ".karajan", "harness");
1170
+ const installed = new Map();
1005
1171
  const mismatched = [];
1006
1172
  for (const [name, body] of Object.entries(SCRIPT_BODIES)) {
1007
1173
  try {
1008
- if (readFileSync(join(dir, name), "utf8") !== body) mismatched.push(name);
1174
+ const text = readFileFn(join(dir, name), "utf8");
1175
+ installed.set(name, text);
1176
+ if (text !== body) mismatched.push(name);
1009
1177
  } catch {
1010
- mismatched.push(name);
1178
+ mismatched.push(name); // missing: there is no content for a seal to cover
1011
1179
  }
1012
1180
  }
1013
- return { ok: mismatched.length === 0, mismatched };
1181
+ if (mismatched.length === 0) return { ok: true, mismatched: [] };
1182
+
1183
+ // A mismatch is only forgiven when what sits there is something a human
1184
+ // sealed. Editing a guard produces content no seal covers, so the forgiveness
1185
+ // cannot be borrowed by also editing a template: that was the hole in the
1186
+ // first attempt at this fix, and the review was right to reject it.
1187
+ const sealed = sealedByPath(root, gitShowFn);
1188
+ const drift = [];
1189
+ const tampered = [];
1190
+ for (const name of mismatched) {
1191
+ const text = installed.get(name);
1192
+ const sealedHash = sealed.get(`.karajan/harness/${name}`);
1193
+ if (text !== undefined && sealedHash && sealedHash === createHash("sha256").update(text).digest("hex")) drift.push(name);
1194
+ else tampered.push(name);
1195
+ }
1196
+ if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
1197
+ return {
1198
+ ok: true,
1199
+ mismatched: [],
1200
+ drift,
1201
+ reason: "el harness instalado está sellado y las plantillas de este árbol van por delante (kj harden lo pone al día) — no es manipulación",
1202
+ };
1014
1203
  }
1015
1204
 
1016
- /** Write the sentinel scripts (shared lib + state writer + gates) and wire them. */
1017
- export function installSentinelHooks({ projectDir = process.cwd(), logger = console } = {}) {
1018
- const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) =>
1019
- writeHarnessScript(projectDir, name, body),
1020
- );
1205
+ /**
1206
+ * Write the sentinel scripts (shared lib + state writer + gates) and wire them.
1207
+ *
1208
+ * KJC-BUG-0193: a guard holding exactly what a human sealed is LEFT ALONE. This
1209
+ * is the command a session reaches for when it has edited the templates, and
1210
+ * rewriting the guards here un-sealed the ones actually running, so the very act
1211
+ * of unblocking destroyed the seal that made unblocking unnecessary. Advancing
1212
+ * them is a human act: `kj harden --commit` moves guards and seal together.
1213
+ * Anything nobody sealed is rewritten as before, because restoring a tampered
1214
+ * guard is still the right move.
1215
+ */
1216
+ export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
1217
+ const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1218
+ const deferred = [];
1219
+ const keepsItsSeal = (name) => {
1220
+ const sealedHash = sealed.get(`.karajan/harness/${name}`);
1221
+ if (!sealedHash) return false;
1222
+ try {
1223
+ return sealedHash === createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "harness", name), "utf8")).digest("hex");
1224
+ } catch {
1225
+ return false; // no hay contenido que un sello pueda cubrir
1226
+ }
1227
+ };
1228
+ const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
1229
+ if (keepsItsSeal(name)) {
1230
+ deferred.push(name);
1231
+ return join(projectDir, ".karajan", "harness", name);
1232
+ }
1233
+ return writeHarnessScript(projectDir, name, body);
1234
+ });
1235
+ if (deferred.length > 0) {
1236
+ logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1237
+ }
1021
1238
  const { wired } = mergeClaudeHooks({
1022
1239
  projectDir,
1023
1240
  logger,
@@ -1033,5 +1250,5 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1033
1250
  { event: "Stop", script: "stop.mjs" },
1034
1251
  ],
1035
1252
  });
1036
- return { scripts: [lib, post, stop, pre], wired };
1253
+ return { scripts: [lib, post, stop, pre], wired, deferred };
1037
1254
  }
@@ -16,6 +16,13 @@ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
16
16
 
17
17
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
18
18
  const HOOKS_PREFIX = ".karajan/hooks/";
19
+ // KJC-BUG-0197: la procedencia del supervisor no cubría los guardias del
20
+ // supervisor. Sellaba los hooks de git y ningún .mjs del harness, que son
21
+ // justo los que vigilan la sesión, así que nada externo podía decir si un
22
+ // guardia instalado era auténtico. Los ficheros siguen fuera de git (los
23
+ // veredictos son locales por diseño); lo que viaja y se firma es su HUELLA,
24
+ // dentro de una procedencia que sí está trackeada.
25
+ const HARNESS_PREFIX = ".karajan/harness/";
19
26
 
20
27
  const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
21
28
 
@@ -58,6 +65,23 @@ function defaultConfirm(nonce) {
58
65
  }
59
66
 
60
67
  /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
68
+ /**
69
+ * KJC-BUG-0197: los guardias del Sentinel que el sello debe cubrir. No salen de
70
+ * `git status` (están gitignorados), así que se leen del disco. Un proyecto sin
71
+ * harness (perfil minimal) sella los hooks y nada más.
72
+ * @returns {string[]} rutas relativas, ordenadas
73
+ */
74
+ export function harnessGuards(projectDir) {
75
+ try {
76
+ return readdirSync(join(projectDir, HARNESS_PREFIX))
77
+ .filter((f) => f.endsWith(".mjs"))
78
+ .map((f) => HARNESS_PREFIX + f)
79
+ .sort();
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
61
85
  export function supervisorDrift({ projectDir, gitFn }) {
62
86
  const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
63
87
  // Un rename sale como "R old -> new" (catch de codex): ambas rutas son
@@ -107,10 +131,26 @@ export async function commitSupervisorRegeneration({
107
131
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
108
132
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
109
133
  // ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
110
- const current = readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f).sort();
134
+ const current = [
135
+ ...readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f),
136
+ ...harnessGuards(projectDir),
137
+ ].sort();
138
+ // KJC-BUG-0197: un guardia BORRADO se anota como borrado, igual que un hook.
139
+ // No hacía falta para desencadenar el re-sello (la lista encoge, así que la
140
+ // cobertura ya falla), pero registrar la ausencia dice más que omitirla:
141
+ // quien lea la procedencia ve que ese guardia se fue, no que nunca estuvo.
142
+ const previousGuards = (() => {
143
+ try {
144
+ return JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8"))?.files ?? [];
145
+ } catch { return []; }
146
+ })();
147
+ const goneGuards = previousGuards
148
+ .map((f) => f?.file)
149
+ .filter((f) => typeof f === "string" && f.startsWith(HARNESS_PREFIX) && !existsSync(join(projectDir, f)));
111
150
  const hashed = [
112
151
  ...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
113
152
  ...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
153
+ ...goneGuards.map((file) => ({ file, deleted: true })),
114
154
  ];
115
155
  let previous = null;
116
156
  try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }