karajan-code 4.65.1 → 4.66.1

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.
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel pretooluse gate (KJC-TSK-0714) — managed by `kj harden`.
3
+ // Consults the session method state BEFORE the tool runs: the rule fires
4
+ // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
5
+ // remediation); read-only tools are never wired here; every honored escape
6
+ // is recorded as an auditable event. Fails OPEN on anything unexpected.
7
+ import console from "node:console";
8
+ import process from "node:process";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
11
+ import { spawnSync } from "node:child_process";
12
+ import { homedir } from "node:os";
13
+ import { fileURLToPath } from "node:url";
14
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
15
+ // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
16
+ import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
17
+ import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
18
+ import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
19
+ const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
20
+ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
21
+ // KJC-BUG-0333: a publish is read per simple command by the shell reader, so
22
+ // words inside a quoted argument of another command are not a publish.
23
+ const isPublish = isPublishCommand;
24
+ const PROTECTED = /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\//;
25
+ let raw = "";
26
+ process.stdin.on("data", (d) => { raw += d; });
27
+ process.stdin.on("end", () => {
28
+ try {
29
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
30
+ // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
31
+ // KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
32
+ // gate's own words: kj handoff takes the why from here, never from the agent.
33
+ const said = [];
34
+ const errOrig = console.error.bind(console);
35
+ console.error = (m) => { said.push(String(m)); errOrig(m); };
36
+ process.on("exit", (code) => {
37
+ if (code !== 2) return;
38
+ errOrig("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
39
+ // Only what a handoff needs: the exact Bash command, or the path an edit aimed at; never file contents.
40
+ const what = tool === "Bash" ? { command: input.command } : { file_path: input.file_path || input.notebook_path || null };
41
+ try { appendFileSync(join(dirname(STATE), "denials.jsonl"), JSON.stringify({ at: new Date().toISOString(), sid, tool, input: what, why: said.join("\n") }) + "\n"); } catch { /* the deny stands without its record */ }
42
+ });
43
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
44
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
45
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
46
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
47
+ const noteFirstTouch = (relT) => {
48
+ const st = load();
49
+ const touch = (session(st, sid).first_touch ||= {});
50
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
51
+ if (Object.hasOwn(touch, relT)) return;
52
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
53
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
54
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
55
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
56
+ save(st);
57
+ };
58
+ // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
59
+ // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
60
+ // only the human, editing outside it.
61
+ if (EDIT_TOOLS.includes(tool)) {
62
+ const target = input.file_path || input.notebook_path;
63
+ const relT = target ? relative(ROOT, String(target)).replaceAll("\\", "/") : "";
64
+ if (relT && PROTECTED.test(relT)) {
65
+ console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
66
+ process.exit(2);
67
+ }
68
+ // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
69
+ // like the supervisor: a session does not loosen the rules that govern it.
70
+ // .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
71
+ // .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
72
+ // .karajan/security-level.json (KJC-TSK-1054, ADR 0020): only kj security writes it, after the human layers.
73
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".karajan/security-level.json", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
74
+ console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
75
+ process.exit(2);
76
+ }
77
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
78
+ }
79
+ // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
80
+ if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
81
+ // Any Bash that NAMES the supervisor's files is denied — a write-verb
82
+ // blocklist is bypassable (cp, dd, one-liners), and reading them is what
83
+ // the Read/Grep tools are for. Shell indirection (variables, globs,
84
+ // substitution) cannot be resolved from command text: that vector is
85
+ // caught by the Stop gate's checksum verification, which blocks the turn.
86
+ // KJC-TSK-1114: where the host sandbox denies writing them, the sandbox guards them instead.
87
+ const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
88
+ if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
89
+ console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
90
+ process.exit(2);
91
+ }
92
+ // KJC-BUG-0164: todo gh que PUBLICA texto (pr/issue/release, create/edit/
93
+ // comment/review) se escanea contra el patron de ATRIBUCION a IA antes de
94
+ // salir — comando inline Y ficheros --body-file/--notes-file. Regla
95
+ // determinista del proyecto: prohibido, sin escape. Un fichero ilegible
96
+ // tampoco publica (fail closed).
97
+ {
98
+ // Regex SIN backslashes a proposito (clases de caracteres, emoji
99
+ // literal): asi la plantilla y el fichero generado son identicos y no
100
+ // hay doble-escape que leer mal. KJC-BUG-0402: [-] es un guion y [ ] un espacio; asi,
101
+ // el escaneo de atribucion del pre-commit no toma al detector por una atribucion.
102
+ const ghCmd = String(input.command || "");
103
+ if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
104
+ const attrib = /co-authored[-]by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated[ ]with [[]?claude|🤖/is;
105
+ // KJC-BUG-0340: a body file's PATH is not published text (a scratch dir
106
+ // under .../claude-1000/... read as an attribution next to a title saying
107
+ // "generated by kj"); its content is. Each piece is judged on its own, so a
108
+ // match never spans from the command into a file.
109
+ const pieces = [];
110
+ let cmdText = ghCmd;
111
+ // KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
112
+ for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
113
+ cmdText = cmdText.split(bodyPath).join(" ");
114
+ try { pieces.push(readFileSync(bodyPath, "utf8")); } catch {
115
+ console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
116
+ process.exit(2);
117
+ }
118
+ }
119
+ pieces.push(cmdText);
120
+ if (pieces.some((p) => attrib.test(p))) {
121
+ console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
122
+ process.exit(2);
123
+ }
124
+ }
125
+ }
126
+ // KJC-BUG-0252 (#1899): una issue para karajan-code sale por kj report-issue
127
+ // (sanitiza y busca duplicados), nunca por gh issue create a mano.
128
+ {
129
+ const ghIssue = String(input.command || "");
130
+ // KJC-BUG-0306: the subcommand itself (gh, its flags, issue create), not three words
131
+ // anywhere: "gh pr create --title 'kj report-issue ...'" is not an issue.
132
+ if (tool === "Bash" && isGhIssueCreate(ghIssue)) {
133
+ // The target: --repo/-R, an inline or inherited GH_REPO, else the
134
+ // checkout's own origin, which is what gh takes (review catches).
135
+ const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
136
+ let target = optionValues(ghIssue, ["--repo", "-R"]).at(-1) || (inlineRepo ? inlineRepo[2] : "") || String(process.env.GH_REPO || "");
137
+ if (!target) {
138
+ const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: ROOT, encoding: "utf8" });
139
+ target = String(origin.stdout || "").trim();
140
+ }
141
+ if (/karajan-code([^a-z0-9]|$)/i.test(target)) {
142
+ console.error("karajan sentinel: las issues para karajan-code salen por kj report-issue (sanitiza y busca issues parecidas), no por gh issue create." + doc("report-issue"));
143
+ process.exit(2);
144
+ }
145
+ }
146
+ }
147
+ // KJC-TSK-0822 v1.1: la clave del movil (capa 5) no se toca desde la sesion.
148
+ // KJC-TSK-1084 (ADR 0015): los comandos de acto humano (kj harden --commit,
149
+ // kj identity enroll-phone) ya no se leen aqui: la propia orden rechaza un
150
+ // proceso que desciende de un agente (src/harden/human-act.js). Queda el
151
+ // FICHERO de la clave, que solo esto protege hasta que el sandbox lo vete.
152
+ {
153
+ // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es escribirlo.
154
+ const namesPhoneFile = stripTextOnlyCommandValues(String(input.command || "")).includes("supervisor-phone.json");
155
+ const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
156
+ if ((tool === "Bash" && namesPhoneFile) || writesEnroll) {
157
+ console.error("karajan sentinel: la clave del movil (capa 5) es del humano — supervisor-phone.json no se toca desde la sesion; enrolar es kj identity enroll-phone desde su terminal." + doc("supervisor"));
158
+ process.exit(2);
159
+ }
160
+ }
161
+ // KJC-BUG-0238 (#1886): un agente no descarta cambios que no hizo. Sin
162
+ // escape y antes de KJ_SENTINEL_OFF: es perdida de datos del usuario, y
163
+ // guardarlo (git stash push) nunca pierde nada.
164
+ if (tool === "Bash") {
165
+ const cmdD = String(input.command || "");
166
+ // A git discard nested in $( ), <( ), backticks, eval, xargs or sh -c cannot be read: fail-closed.
167
+ const nested = ["$(", "<(", ">(", String.fromCharCode(96)].some((n) => cmdD.includes(n)) || /(^|[ ;&|])(eval|xargs|bash|sh|zsh|env)( |$)/.test(cmdD);
168
+ if (nested && /(^|[^a-z])git([^a-z]|$)/.test(cmdD) && DISCARD_VERBS.some((v) => cmdD.includes(v))) {
169
+ console.error("karajan sentinel: un descarte git dentro de $( ), backticks, eval, xargs o sh -c no es verificable — ejecutalo como comando simple." + doc("discard"));
170
+ process.exit(2);
171
+ }
172
+ const touch = load().sessions?.[sid]?.first_touch || {};
173
+ for (const words of shellSegments(cmdD)) {
174
+ const d = discardOf(words, ROOT);
175
+ const lost = d ? foreignLost(d, touch, ROOT) : [];
176
+ if (lost.length === 0) continue;
177
+ console.error(d.stash
178
+ ? "karajan sentinel: git stash drop/clear borra trabajo guardado que puede no ser de esta sesion — pideselo a tu usuario." + doc("discard")
179
+ : "karajan sentinel: ese comando descarta cambios que esta sesion no hizo (" + lost.slice(0, 5).join(", ") + (lost.length > 5 ? ", +" + (lost.length - 5) : "") + ") — pueden ser trabajo sin commitear de tu usuario. Si estorban, guardalos recuperables (git stash push -- <ficheros>) y avisale; si el cambio es tuyo sobre su trabajo, deshazlo con Edit. Sin escape: es perdida de datos." + doc("discard"));
180
+ process.exit(2);
181
+ }
182
+ }
183
+ if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
184
+ // ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
185
+ // text is read only by the identity lock below.
186
+ const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
187
+ const ESC_TAB = String.fromCharCode(9);
188
+ const ESC_NL = String.fromCharCode(10);
189
+ // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
190
+ // run under an account other than the one THIS CLONE declared (the incident:
191
+ // a gh comment without switch went out as the client account). Deny BEFORE
192
+ // the damage with the literal remedy; never auto-switch; undeclared = closed.
193
+ if (tool === "Bash" && CMD_TEXT.length > 0) {
194
+ const idFile = join(ROOT, ".karajan", "identity.local.yml");
195
+ const idLine = (key) => {
196
+ if (!existsSync(idFile)) return null;
197
+ const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
198
+ const v = line ? line.slice(key.length + 1).trim() : "";
199
+ return v.length > 0 ? v : null;
200
+ };
201
+ const declaredGh = idLine("gh_user");
202
+ const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
203
+ const activeGh = () => {
204
+ if (!existsSync(ghHostsPath)) return null;
205
+ const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
206
+ const start = lines.findIndex((l) => l.startsWith("github.com:"));
207
+ if (start < 0) return null;
208
+ for (let k = start + 1; k < lines.length; k += 1) {
209
+ if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
210
+ const t = lines[k].trim();
211
+ if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
212
+ }
213
+ return null;
214
+ };
215
+ const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
216
+ const headOf = (ws) => { let k = 0; while (k < ws.length && ws[k].indexOf("=") > 0 && ws[k].indexOf("=") < ws[k].length) k += 1; return ws.slice(k); };
217
+ // Wrapper shells hide the payload in a string (review catch): scan inside.
218
+ const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
219
+ let switchedTo = null;
220
+ const problems = [];
221
+ // $( and backticks are separators too (review catch): their payload is its own segment.
222
+ const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
223
+ const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
224
+ const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
225
+ const checkSeg = (ws, depth) => {
226
+ if (ws.length === 0) return;
227
+ if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
228
+ const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
229
+ if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
230
+ return;
231
+ }
232
+ if (ws[0] === "gh") {
233
+ if (ws[1] === "auth") {
234
+ if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
235
+ return; // session commands ARE the remedy — never blocked
236
+ }
237
+ if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
238
+ const eff = switchedTo || activeGh();
239
+ if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
240
+ }
241
+ // KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
242
+ // pre-commit checks the author, pre-push every pushed commit and the gh session.
243
+ };
244
+ scan(CMD_TEXT, 0);
245
+ if (problems.length > 0) {
246
+ console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(sin escape, ADR 0015: pon la cuenta en el comando)" + doc("identity"));
247
+ process.exit(2);
248
+ }
249
+ }
250
+ // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
251
+ // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
252
+ const laneOf = (p) => {
253
+ // Sube al primer ancestro EXISTENTE (un subdir aun no creado escapaba).
254
+ let d = String(p);
255
+ if (!(existsSync(d) && statSync(d).isDirectory())) d = dirname(d);
256
+ while (d && !existsSync(d)) {
257
+ const up = dirname(d);
258
+ if (up === d) return null;
259
+ d = up;
260
+ }
261
+ // Canonicaliza (reviewer catch: un symlink local hacia otro carril
262
+ // esquivaba la clasificacion por ruta literal).
263
+ try { d = realpathSync(d); } catch { /* se clasifica tal cual */ }
264
+ return d ? foreignLane(d) : null;
265
+ };
266
+ const laneDeny = (what, lane) => {
267
+ console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
268
+ return true;
269
+ };
270
+ if (EDIT_TOOLS.includes(tool)) {
271
+ const target = input.file_path || input.notebook_path;
272
+ const lane = target ? laneOf(target) : null;
273
+ if (lane && laneDeny(String(target), lane)) process.exit(2);
274
+ }
275
+ if (tool === "Bash") {
276
+ const cmd = String(input.command || "");
277
+ // KJC-BUG-0166/0167: gestionar worktrees crudos (add/remove/prune) es
278
+ // estructura del repo, jamas trabajo de una sesion. 0166: una sesion
279
+ // intento EN BUCLE retirar el ARBOL PRINCIPAL con remove+CROSS_LANE.
280
+ // 0167: un coder descarrilado ANADIO un worktree .verify-main rebelde y
281
+ // de ahi se invento un bootstrap. Deny sin escape en los tres verbos;
282
+ // el cauce sancionado es kj worktree start|done (corre dentro de kj).
283
+ if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(add|remove|prune)/.test(cmd)) {
284
+ console.error("karajan sentinel: git worktree add/remove/prune no se ejecuta desde una sesion — estructura del repo, sin escape; usa kj worktree start|done." + doc("cross-lane"));
285
+ process.exit(2);
286
+ }
287
+ // KJC-TSK-1114: where the host sandbox denies writing the lanes, it confines Bash itself.
288
+ if (!sandboxCovers(ROOT, [".kj/worktrees"])) {
289
+ // Deny-unless-known-read-only: un allowlist de verbos mutadores era
290
+ // esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
291
+ // encadenar ni redirigir) puede nombrar otro carril; leer carriles es
292
+ // trabajo de las tools Read/Grep, como con los ficheros del supervisor.
293
+ // Solo LITERALES: expansión/sustitución en un comando "de lectura"
294
+ // puede ejecutar cualquier cosa (reviewer catch: grep foo $(rm ...)).
295
+ // (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
296
+ // carril los caza el escaner de abajo.)
297
+ // KJC-BUG-0338: kj rag query only reads, and the rag-first gate asks for it
298
+ // with a quoted question that names the file.
299
+ const READONLY = /^[ \t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame)|kj rag query)\b[^;|&<>$`(){}\n\r]*$/;
300
+ // KJC-BUG-0286 (#1982): a command whose EVERY segment only reads mutates
301
+ // nothing, however it is chained (grep | grep, git show | grep, cd && git
302
+ // status). A cd with a literal target is one such segment: the paths after
303
+ // it need no verifying when nothing after it writes. One mutating segment
304
+ // and the whole command is judged below, as before.
305
+ const CD_LITERAL = /^[ \t]*(cd|pushd)([ \t]+[^ \t;|&<>$`(){}\n\r]*)?[ \t]*$/;
306
+ // KJC-BUG-0365: 2>&1 and 2>/dev/null only fold stderr, they write nothing.
307
+ const QUIET = /[ \t]2>(?:&1|\/dev\/null)(?=[ \t]|$)/g;
308
+ const readsOnly = (s) => s.split(/&&|\|\||[;|]/).every((seg) => READONLY.test(seg.replace(QUIET, "")) || CD_LITERAL.test(seg.replace(QUIET, "")));
309
+ // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
310
+ if (!readsOnly(stripInertQuotes(cmd))) {
311
+ // cd/pushd invalida TODO razonamiento textual de rutas posteriores
312
+ // (carrera de bypasses confirmada en review: destino bare, con $,
313
+ // relativas post-cd...): en un comando NO-read-only, cambiar de
314
+ // directorio se deniega conservadoramente — el remedio es no
315
+ // necesitarlo (git -C, npm --prefix, rutas absolutas).
316
+ if (/(^|[;&|(\s])(cd|pushd)([ \t]|$)/.test(cmd)) {
317
+ console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd." + doc("cross-lane"));
318
+ process.exit(2);
319
+ }
320
+ // Absolute AND dot-relative tokens (../lane escapaba al primer scan —
321
+ // reviewer catch); relative se resuelve contra el cwd de la sesion.
322
+ // EVERY token (un tope seria un bypass); dedup acota el coste git.
323
+ // Fallo de solomon (seguridad no arbitrable) + carrera de bypasses:
324
+ // la expansion ($, backtick) solo se tolera en segmentos que son
325
+ // asignaciones puras o runners del toolchain propio (npm/pnpm/yarn/
326
+ // vitest/jest/kj/gh/echo/printf, git sin -C/--git-dir/--work-tree) —
327
+ // sus argumentos son datos. En herramientas genericas de fichero o
328
+ // interpretes el objetivo queda oculto al texto => deny. Los paths
329
+ // LITERALES (tambien dentro de sustituciones) los escanea el bucle
330
+ // de tokens de abajo; el residuo (expansion anidada en segmentos
331
+ // runner) queda documentado como en la regla de ficheros PROTECTED.
332
+ if (/>{1,2}[ \t]*["']?[$`]/.test(cmd)) {
333
+ console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL." + doc("cross-lane"));
334
+ process.exit(2);
335
+ }
336
+ // Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
337
+ // en cualquier posicion, y una ruta entrecomillada con espacios es
338
+ // invisible al tokenizador: ambas => deny conservador (endpoint
339
+ // pedido por el reviewer; friccion asumida, el remedio es literal).
340
+ // KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
341
+ // la comilla de CIERRE de un string + un redirect literal + la de
342
+ // apertura del siguiente creaban la ilusion de ruta con espacios y
343
+ // denegaban el flujo estandar commit -m "..." > log && echo "ok".
344
+ // Se recorre alternando estado (doctrina del parseCommand del motor):
345
+ // solo el CONTENIDO realmente entrecomillado con slash y espacio
346
+ // deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
347
+ // y la comilla sin cerrar (no verificable).
348
+ // ...y por TOKEN completo, no por span: spans adyacentes concatenan
349
+ // ("dir with"" spaces/file" es UNA ruta) — catch de codex.
350
+ const quotedPathWithSpaces = (s) => {
351
+ // Un backslash pegado a comilla o blanco (comilla escapada,
352
+ // espacio escapado) altera donde CIERRA el string o esconde
353
+ // espacios: opaco => deny (doctrina del motor) — catch de codex.
354
+ // Caracteres construidos con fromCharCode: CERO escaping en este
355
+ // template — fuente y artefacto identicos, nada que malinterpretar
356
+ // (el doble-escaping confundio cuatro reviews; solomon desestimo
357
+ // la primera y las demas se volvieron ilegibles igual).
358
+ // El punto escapado de un sed y similares siguen libres.
359
+ const BS = String.fromCharCode(92);
360
+ const TAB = String.fromCharCode(9);
361
+ const NL = String.fromCharCode(10);
362
+ for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
363
+ const n = s[i + 1] || "";
364
+ if (n === '"' || n === "'" || n === " " || n === TAB) return true;
365
+ }
366
+ let q = null;
367
+ let tok = "";
368
+ let hadQuote = false;
369
+ const flush = () => {
370
+ const bad = hadQuote && tok.includes("/") && tok.includes(" ");
371
+ tok = "";
372
+ hadQuote = false;
373
+ return bad;
374
+ };
375
+ for (const ch of s) {
376
+ if (q) {
377
+ if (ch === q) q = null;
378
+ else tok += ch;
379
+ continue;
380
+ }
381
+ if (ch === '"' || ch === "'") {
382
+ q = ch;
383
+ hadQuote = true;
384
+ continue;
385
+ }
386
+ if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
387
+ if (flush()) return true;
388
+ continue;
389
+ }
390
+ tok += ch;
391
+ }
392
+ if (q !== null) return true; // comilla sin cerrar: no verificable
393
+ return flush();
394
+ };
395
+ // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
396
+ // of a text option (--title, -m...) is not a path.
397
+ if (/\$\(|`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
398
+ console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion. Si es texto (titulo, descripcion, error) con comillas de codigo o dolar, ponlo entre comillas SIMPLES: entre dobles la shell lo ejecuta." + doc("cross-lane"));
399
+ process.exit(2);
400
+ }
401
+ const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \t]*[ \t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\b|git[ \t](?![^\n]*(-C[ \t]|--git-dir|--work-tree)))[^;|&\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\n]*$/;
402
+ const segs = cmd.split(/&&|\|\||[;|\n]/).map((s) => s.trim()).filter(Boolean);
403
+ if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
404
+ console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain." + doc("cross-lane"));
405
+ process.exit(2);
406
+ }
407
+ // ...incluidas relativas A PELO (los carriles kj viven en
408
+ // .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
409
+ // y foreignLane da null — solo deniega lo que CAE en otro carril.
410
+ const seen = new Set();
411
+ for (const m of cmd.matchAll(/(?:^|[\s"'=;(><])((?:\.\.?\/)[^\s"'`;|&)]*|\/[^\s"'`;|&)]+|~\/[^\s"'`;|&)]+|[A-Za-z0-9_.@-]+\/[^\s"'`;|&)]+)/g)) {
412
+ const t = m[1].startsWith("~/") ? (process.env.HOME || "~") + m[1].slice(1) : m[1];
413
+ const abs = resolve(t);
414
+ if (seen.has(abs)) continue;
415
+ seen.add(abs);
416
+ const lane = laneOf(abs);
417
+ if (lane && laneDeny(m[1], lane)) process.exit(2);
418
+ }
419
+ }
420
+ }
421
+ }
422
+ // KJC-BUG-0237 (#1886): the repo is written through Edit/Write only, the path every gate
423
+ // guards. After the lane guard, whose message is the precise one for another lane.
424
+ // KJC-TSK-1114: gates run at commit now; what only this guard kept, the governance
425
+ // files, the host sandbox denies where it covers them.
426
+ if (tool === "Bash" && !sandboxCovers(ROOT, ["kj.config.yml", ".ragignore"])) {
427
+ let moved = false; // after cd/pushd/popd, a relative target cannot be placed: fail-closed
428
+ // $( ) and backticks run even inside double quotes: their bodies, read from the raw text, are commands too.
429
+ const raw = String(input.command || "");
430
+ const subs = [...[...raw.matchAll(/[$][(]([^()]*)[)]/g)].map((m) => m[1]), ...raw.split(String.fromCharCode(96)).filter((_, k) => k % 2 === 1)];
431
+ for (const words of [...shellSegments(raw), ...subs.flatMap((s) => shellSegments(s))]) {
432
+ if (["cd", "pushd", "popd"].includes(words[0])) moved = true;
433
+ const inRepo = shellWrites(words).filter((t) => writesRepo(t, ROOT) || (moved && !t.startsWith("/")));
434
+ if (inRepo.length === 0) continue;
435
+ console.error("karajan sentinel: escribir ficheros del repo desde Bash (" + inRepo.slice(0, 3).join(", ") + ") se salta los gates de Edit/Write — usa la tool Edit/Write; para renombrar, git mv; fuera del repo (/tmp, scratchpad) Bash es libre." + doc("bash-write"));
436
+ process.exit(2);
437
+ }
438
+ }
439
+ // KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
440
+ // la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
441
+ // PL-A); los defaults del supervisor viven en el motor y el check
442
+ // PROTECTED inline de arriba queda como red fail-closed si kj falta.
443
+ // Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
444
+ // gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
445
+ // deny duro (el remedio es restaurar kj); cualquier otro fallo de
446
+ // evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
447
+ // usuario fuera de la sesion.
448
+ if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
449
+ // PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
450
+ // kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
451
+ const actorRole = process.env.KJ_POLICY_ROLE || "coder";
452
+ const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
453
+ if (pres.error || pres.status === null) {
454
+ console.error("karajan sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente." + doc("policy"));
455
+ process.exit(2);
456
+ }
457
+ if (pres.status === 2) {
458
+ let v = null;
459
+ try { v = JSON.parse(String(pres.stdout || "").trim().split("\n").pop()); } catch { /* mensaje generico */ }
460
+ const secure = !!(v && v.class === "security");
461
+ // ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
462
+ console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (sin escape: si la regla falla, tu usuario corrige .karajan/policy.yml por PR)") + doc("policy"));
463
+ process.exit(2);
464
+ } else if (pres.status !== 0) {
465
+ // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
466
+ // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
467
+ // la sesion sin Edit, sin Write y sin Bash, o sea sin las herramientas
468
+ // para arreglar el fichero, y el remedio que sugeria (kj policy check)
469
+ // tampoco arrancaba. Se avisa fuerte y se deja pasar: el resto de este
470
+ // guard sigue vigente, que es lo que protege el metodo.
471
+ const broken = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.exec(String(pres.stderr || ""));
472
+ // Abrir el gate entero seria un bypass a la carta (romper un modulo y
473
+ // seguir sin policy), catch de la review. Se permite EXACTAMENTE una
474
+ // cosa: editar el fichero que rompe kj. Lo demas sigue denegado.
475
+ const at = broken ? (String(pres.stderr || "").split("\n").map((l) => l.trim()).find((l) => /^[^ ]+\.(m?js|cjs|ts):\d+$/.test(l)) || "") : "";
476
+ // Node localiza los errores de un modulo ESM como file:///ruta.js:124,
477
+ // y esa forma no es una ruta de fichero (catch de la review).
478
+ const asPath = (p) => (p.startsWith("file:") ? fileURLToPath(p) : p);
479
+ const brokenFile = at ? asPath(at.slice(0, at.lastIndexOf(":"))) : "";
480
+ if (broken) {
481
+ const target = input.file_path || input.notebook_path || "";
482
+ const repairing = EDIT_TOOLS.includes(tool) && brokenFile && target && resolve(String(target)) === resolve(brokenFile);
483
+ 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"));
484
+ else {
485
+ 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"));
486
+ process.exit(2);
487
+ }
488
+ } else {
489
+ 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; .karajan/policy.yml lo corrige tu usuario fuera de la sesion." + doc("policy"));
490
+ process.exit(2);
491
+ }
492
+ }
493
+ }
494
+ if (EDIT_TOOLS.includes(tool)) {
495
+ // STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
496
+ // STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
497
+ // almost never — only security and persistent red main, and only when
498
+ // the project adopted it (method_gates.steward: block). Unknown is never
499
+ // treated as broken: nothing is asserted that is not known. No report =
500
+ // the project does not use the Steward yet — silence, adoption is explicit.
501
+ try {
502
+ const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
503
+ const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
504
+ const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
505
+ if (bad.length || stale.length) {
506
+ const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
507
+ let blockOn = false;
508
+ try { blockOn = /steward:\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
509
+ if (hard.length && blockOn) {
510
+ console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(sin escape, ADR 0015: si el trabajo sigue, tu usuario cambia method_gates.steward a inform)" + doc("steward"));
511
+ process.exit(2);
512
+ }
513
+ const st = load();
514
+ const s = session(st, sid);
515
+ if (!s.steward_informed) {
516
+ s.steward_informed = true;
517
+ save(st);
518
+ const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
519
+ console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
520
+ }
521
+ }
522
+ } catch { /* no report or unreadable — the Steward is not adopted here */ }
523
+ const file = input.file_path || input.notebook_path;
524
+ const rel = file ? relative(ROOT, String(file)).replaceAll("\\", "/") : "";
525
+ // KJC-BUG-0279 (#1981): outside the repository there is no source to govern.
526
+ if (rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/") && CODE.test(rel) && !TESTS.test(rel)) {
527
+ const branch = branchOf();
528
+ const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
529
+ // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
530
+ if (why) {
531
+ console.error(why === "base"
532
+ ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion." + doc("card-first")
533
+ : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion." + doc("card-first"));
534
+ process.exit(2);
535
+ }
536
+ // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
537
+ // zone before the session touches it. Covered = the session ledger
538
+ // (RAG-A) holds this file or a sibling of its directory; a NEW file only
539
+ // needs the session to have consulted at all. Same file set as card-first.
540
+ const rs = load().sessions?.[sid] || {};
541
+ const hits = rs.rag_hits || [];
542
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
543
+ // KJC-BUG-0210: nuevo no es "no existe en disco". En cuanto la sesion
544
+ // lo crea, la siguiente edicion exigia una respuesta del RAG sobre un
545
+ // fichero que el indice NO puede tener. El hecho comprobable: un
546
+ // fichero que git no conoce no ha podido indexarse.
547
+ // Solo se pregunta a git en el camino lento, cuando el gate iba a
548
+ // denegar: exit 1 = git responde que no lo conoce; cualquier otro
549
+ // resultado se comporta como hasta ahora y bloquea.
550
+ const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
551
+ // A path outside this tree is another lane's business (the lane guard
552
+ // above rules on it) and the ledger only holds in-repo paths.
553
+ // KJC-BUG-0190: with nothing indexed, no query can cover anything —
554
+ // the gate asks for kj rag index instead of denying the impossible.
555
+ // KJC-BUG-0308: a barrel only re-exports its folder, so the RAG answers with
556
+ // what it re-exports and never with the barrel: a hit in its own subtree covers it.
557
+ const BARRELS = ["index.js", "index.ts", "index.mjs", "index.cjs", "index.jsx", "index.tsx", "__init__.py", "mod.rs"];
558
+ const barrelCovered = dirOf(rel) !== "" && BARRELS.includes(rel.slice(rel.lastIndexOf("/") + 1)) && hits.some((h) => h.startsWith(dirOf(rel) + "/"));
559
+ const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || barrelCovered || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
560
+ // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
561
+ // index, not indexable) are decided by the gate itself.
562
+ if (!covered) {
563
+ {
564
+ // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
565
+ // sin adapter NO puede estar en el indice, asi que exigir una
566
+ // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
567
+ // dejar el escape puesto para siempre. La exencion es SOLO
568
+ // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
569
+ // y sigue bloqueando, porque si no el gate desapareceria en
570
+ // cualquier proyecto sin indexar. Nada legible = se bloquea.
571
+ const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
572
+ let blind = null;
573
+ let absent = null;
574
+ try {
575
+ const c = JSON.parse(String(cov.stdout || ""));
576
+ if (c.state === "not-indexable") blind = c.reason;
577
+ // KJC-TSK-0891: si el indice no tiene el fichero, consultar no puede
578
+ // devolverlo; el remedio es indexarlo, y se nombra.
579
+ if (c.state === "stale" || c.state === "index-empty") absent = c.reason;
580
+ } catch { blind = null; }
581
+ if (blind) {
582
+ 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"));
583
+ } else if (absent) {
584
+ console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
585
+ process.exit(2);
586
+ } else {
587
+ // KJC-BUG-0293 (#1996): hits that fell outside the tree are named, so the
588
+ // person learns the index was built from another path instead of querying again.
589
+ const dropped = rs.rag_dropped;
590
+ const outside = dropped?.count
591
+ ? " La ultima consulta devolvio " + dropped.count + " hit(s) fuera de este arbol (" + dropped.root + "): " + (dropped.sample || []).join(", ") + " — el indice se construyo desde otra ruta; reindexa desde esta: kj rag index --with-sources."
592
+ : "";
593
+ 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>." + outside + doc("rag-first"));
594
+ process.exit(2);
595
+ }
596
+ }
597
+ }
598
+ }
599
+ }
600
+ if (tool === "Bash") {
601
+ const cmd = String(input.command || "");
602
+ // KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
603
+ // and nothing advances (commit, new PR, another merge; push and the end of
604
+ // the turn go through violations()) until the card is moved. The board is
605
+ // true at all times or the method stops — never a rule in the agent's memory.
606
+ // ENF-B (KJC-TSK-0854): el review es obligatorio, no una sugerencia. Una
607
+ // bandera no puede apagar el gate de commit, y mover core.hooksPath lo
608
+ // apaga entero. El --no-verify que kj harden --commit usa por dentro no
609
+ // pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
610
+ // KJC-BUG-0286 (#1982): judged on the git commit SEGMENT alone, read by the
611
+ // shell reader: a "commit" inside a grep pattern piped after git show, or
612
+ // the -n of a grep in the same pipeline, is not a skipped hook.
613
+ const commitSegments = shellSegments(cmd).map((ws) => ws.map((w) => w.slice(w.lastIndexOf("/") + 1))).filter((ws) => inOrder(["git", "commit"], ws));
614
+ // git acepta abreviaturas no ambiguas de las opciones largas, asi que
615
+ // --no-ver salta el hook igual que --no-verify (catch de la review).
616
+ const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
617
+ const skipsVerify = (w) => shortWithN(w) || (w.length >= 5 && "--no-verify".startsWith(w));
618
+ // Mover core.hooksPath apaga TODOS los hooks. git tiene varias puertas
619
+ // para lo mismo (config, -c, --config-env, GIT_CONFIG_KEY_n) y no
620
+ // distingue mayusculas en la llave, asi que lo que se vigila es la LLAVE
621
+ // en cualquier posicion (dos catches de la review). Leerla no hace nada.
622
+ // Nombrar la llave basta para denegar: exceptuar las lecturas invitaba a
623
+ // colar una detras del cambio (git config core.hooksPath /x; cat algo),
624
+ // catch de la review. Leerla tiene remedio barato, perderla no.
625
+ // Se busca la llave DENTRO del token y no solo al principio: las formas
626
+ // de inyeccion la empotran (GIT_CONFIG_KEY_0=core.hooksPath,
627
+ // --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
628
+ // depender de como parta las palabras el separador (catch de la review).
629
+ const movesHooks = cmd.toLowerCase().includes("core.hookspath");
630
+ if (commitSegments.some((ws) => ws.some(skipsVerify)) || movesHooks) {
631
+ // ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
632
+ // if it ever must happen, is the user's act in their own terminal.
633
+ 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 saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
634
+ process.exit(2);
635
+ }
636
+ // KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
637
+ // git add -A / git add . / git add -u put a Claude scheduler lock into
638
+ // main; and the agent's runtime files (the .claude locks, .kj/, the local
639
+ // identity, the Sentinel state, the harness) are never staged at all.
640
+ // (No backticks in this template: they would end the string.)
641
+ const RUNTIME = /^(\.\/)?(\.claude\/[^/]*\.lock|\.kj\/|\.karajan\/identity\.local\.yml|\.karajan\/harness\/sentinel-state\.json)/;
642
+ for (const ws of shellSegments(cmd)) {
643
+ const gi = ws.findIndex((x) => x.slice(x.lastIndexOf("/") + 1) === "git");
644
+ if (gi < 0) continue;
645
+ // git's own options come before the verb: -C <dir>, -c k=v, --git-dir=…
646
+ let vi = gi + 1;
647
+ while (vi < ws.length && ws[vi].startsWith("-")) vi += ws[vi] === "-C" || ws[vi] === "-c" ? 2 : 1;
648
+ if (ws[vi] !== "add") continue;
649
+ const args = ws.slice(vi + 1);
650
+ // The pathspec: after "--" if present, else every non-option word. "." is
651
+ // the whole tree whichever way it is written; -A/-u with no pathspec too.
652
+ const dash = args.indexOf("--");
653
+ const spec = dash >= 0 ? args.slice(dash + 1) : args.filter((a) => !a.startsWith("-"));
654
+ const whole = spec.some((a) => a === "." || a === "./");
655
+ const blanket = whole || (args.some((a) => a === "-A" || a === "--all" || a === "-u" || a === "--update") && spec.length === 0);
656
+ if (blanket) {
657
+ console.error("karajan sentinel: git add -A / . / -u stagea lo que no has revisado — stagea por nombre (git add -- <ruta>...) y mira git diff --cached --stat antes de commitear." + doc("commit-gate"));
658
+ process.exit(2);
659
+ }
660
+ // Directories too: .kj is runtime whole; .claude and .karajan hold the
661
+ // contract, so only a forced add (-f, past the ignore) reaches their locks.
662
+ const force = args.some((a) => a === "-f" || a === "--force");
663
+ const runtime = spec.find((a) => RUNTIME.test(a) || /^(\.\/)?\.kj\/?$/.test(a) || (force && /^(\.\/)?(\.claude|\.karajan)\/?$/.test(a)));
664
+ if (runtime) {
665
+ console.error("karajan sentinel: " + runtime + " es runtime del agente (locks de .claude, .kj/, identidad local, estado del Sentinel) y no se commitea — fuera del stage." + doc("commit-gate"));
666
+ process.exit(2);
667
+ }
668
+ }
669
+ const MERGE = /\bgh\s+pr\s+merge(\s+(\d+))?/;
670
+ // KJC-TSK-1089: git push is not read any more; pending moves still stop the turn (stop gate).
671
+ const ADVANCE = /\bgit\s+commit\b|\bgh\s+pr\s+create\b/;
672
+ const mergeM = cmd.match(MERGE);
673
+ const pend = pendingMoves(load().sessions?.[sid]);
674
+ if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
675
+ // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
676
+ // por su primera mitad (boardGate, en la lib).
677
+ const { blocking, carried, reason } = boardGate(sid, pend);
678
+ for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
679
+ // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
680
+ // are already decided above (KJC-BUG-0230, 0198).
681
+ // KJC-BUG-0363: when kj could not check, its reason travels with the block.
682
+ if (blocking.length > 0) {
683
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\n" + blocking.map((p) => "- " + pendingText(p)).join("\n") + (reason ? "\n(" + reason + ")" : "") + doc("board-sync"));
684
+ process.exit(2);
685
+ }
686
+ }
687
+ // (the pending entry itself is recorded by the PostToolUse hook, only once
688
+ // gh confirms "merged pull request #N" — a failed merge never blocks)
689
+ // CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
690
+ // there misleads every future reader. When the command writes one from a
691
+ // file, kj crosses ITS data against this turn's outputs; the hook carries
692
+ // no policy (kj reads method_gates.claims; off = this check does not exist).
693
+ // KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
694
+ const body = /\bgh\s+pr\s+(?:create|edit)\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
695
+ if (body && transcript) {
696
+ const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
697
+ if (!g.error && g.status === 2) {
698
+ console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\n" + (g.stderr || "").trim() + "\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
699
+ process.exit(2);
700
+ }
701
+ }
702
+ if (isPublish(cmd)) {
703
+ // ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
704
+ // check from blocking its own fix.
705
+ // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
706
+ // declara remedied_by no bloquea el comando que lo repara (desplegar
707
+ // la landing era justo lo que el check pedia); publicar no se exime.
708
+ const res = spawnSync("kj", ["release", "check", "--json", "--for-command", cmd], { cwd: ROOT, encoding: "utf8" });
709
+ if (res.error || res.status === null) process.exit(0);
710
+ let parsed = null;
711
+ try { parsed = JSON.parse(res.stdout); } catch { /* raw output */ }
712
+ const lifted = (parsed && parsed.lifted) || [];
713
+ if (res.status === 0 && lifted.length > 0) {
714
+ // En voz alta: una exencion que nadie ve no se distingue de un gate que no mira.
715
+ 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"));
716
+ process.exit(0);
717
+ }
718
+ if (res.status !== 0) {
719
+ let items = "";
720
+ try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
721
+ 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(sin escape: se repara lo que el check nombra)" + doc("release"));
722
+ process.exit(2);
723
+ }
724
+ }
725
+ // KJC-TSK-1089 (ADR 0015): git push is no longer read here. The base branch is
726
+ // protected (required checks), the card and the tests are judged at commit
727
+ // (kj review), and pending card moves keep the turn from ending (stop gate).
728
+ }
729
+ } catch { /* fail open */ }
730
+ process.exit(0);
731
+ });