karajan-code 4.25.0 → 4.27.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 +1 -1
- package/packages/hu-board/public/app.js +37 -43
- package/packages/hu-board/public/index.html +15 -11
- package/packages/hu-board/public/styles.css +29 -14
- package/packages/hu-board/public/utils/graph-view.js +1 -1
- package/packages/hu-board/public/utils/init-listeners.js +1 -14
- package/packages/hu-board/public/utils/maggle-mode.js +10 -4
- package/packages/hu-board/public/utils/project-actions.js +4 -5
- package/packages/hu-board/public/utils/project-picker-view.js +1 -1
- package/packages/hu-board/src/db.js +13 -0
- package/packages/hu-board/src/sync.js +6 -1
- package/src/cli/register-meta.js +4 -0
- package/src/commands/harden.js +22 -0
- package/src/commands/policy.js +26 -8
- package/src/commands/review-gate.js +20 -5
- package/src/harden/harden-engine.js +21 -1
- package/src/harden/hook-templates.js +12 -2
- package/src/harden/sentinel-hooks.js +46 -31
- package/src/harden/supervisor-commit.js +147 -0
- package/src/harden/workflow-templates.js +3 -1
- package/src/policy/engine.js +3 -1
- package/src/policy/exceptions.js +49 -13
- package/src/policy/supervisor-verify.js +79 -0
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
11
13
|
const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
|
|
12
14
|
const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
|
|
13
15
|
|
|
@@ -27,10 +29,18 @@ export const PROFILE_HOOKS = {
|
|
|
27
29
|
// guarded, so machines without it are unaffected.
|
|
28
30
|
function chainToGlobal(hook, globalHooksDir) {
|
|
29
31
|
if (!globalHooksDir) return [];
|
|
32
|
+
// KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
|
|
33
|
+
// absolute home path — resolve through $HOME at runtime so the same
|
|
34
|
+
// generated content is valid on every machine and its provenance stays
|
|
35
|
+
// verifiable (and no local filesystem layout leaks into a public repo).
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
|
|
38
|
+
? `$HOME${globalHooksDir.slice(home.length)}`
|
|
39
|
+
: globalHooksDir;
|
|
30
40
|
return [
|
|
31
41
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
32
|
-
`if [ -x "${
|
|
33
|
-
` "${
|
|
42
|
+
`if [ -x "${dir}/${hook}" ]; then`,
|
|
43
|
+
` "${dir}/${hook}" "$@"; rc=$?`,
|
|
34
44
|
' [ "$rc" -eq 0 ] || exit "$rc"',
|
|
35
45
|
"fi",
|
|
36
46
|
];
|
|
@@ -43,6 +43,7 @@ export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
|
|
|
43
43
|
export const BASE_BRANCHES = new Set(["main", "master"]);
|
|
44
44
|
export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
45
45
|
export const save = (state) => writeFileSync(STATE, JSON.stringify(state, null, 2));
|
|
46
|
+
export const doc = (a) => " — doc: https://karajancode.com/docs/guides/sentinel/#" + a;
|
|
46
47
|
export const session = (state, sid) => {
|
|
47
48
|
state.sessions ||= {};
|
|
48
49
|
return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
|
|
@@ -108,9 +109,9 @@ export const recordEscape = (sid, escape, tool) => {
|
|
|
108
109
|
const args = ["policy", "seal", "--escape", escape];
|
|
109
110
|
if (tool) args.push("--tool", String(tool));
|
|
110
111
|
const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
|
|
111
|
-
if (r.error || r.status !== 0) console.error("
|
|
112
|
+
if (r.error || r.status !== 0) console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json" + doc("escapes"));
|
|
112
113
|
} catch (e) {
|
|
113
|
-
console.error("
|
|
114
|
+
console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message + doc("escapes"));
|
|
114
115
|
}
|
|
115
116
|
};
|
|
116
117
|
`;
|
|
@@ -121,7 +122,7 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
121
122
|
// a tool call (PostToolUse, always exit 0).
|
|
122
123
|
import { relative } from "node:path";
|
|
123
124
|
import { spawnSync } from "node:child_process";
|
|
124
|
-
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
125
|
+
import { doc, CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
125
126
|
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"];
|
|
126
127
|
let raw = "";
|
|
127
128
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -233,7 +234,7 @@ import { spawnSync } from "node:child_process";
|
|
|
233
234
|
import { readFileSync } from "node:fs";
|
|
234
235
|
import { homedir } from "node:os";
|
|
235
236
|
import { join } from "node:path";
|
|
236
|
-
import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
237
|
+
import { doc, load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
237
238
|
// KJC-TSK-0774: when the turn ends green with cards closed, remind the user
|
|
238
239
|
// where to look — the board URL if it runs (pid file alive), else how to start it.
|
|
239
240
|
const boardHint = () => {
|
|
@@ -274,10 +275,10 @@ process.stdin.on("end", () => {
|
|
|
274
275
|
state.tamper_blocks = (state.tamper_blocks || 0) + 1;
|
|
275
276
|
save(state);
|
|
276
277
|
if (state.tamper_blocks > 3) {
|
|
277
|
-
console.error("
|
|
278
|
+
console.error("karajan sentinel: fail-open tras 3 bloqueos por manipulacion sin restaurar — corre kj harden y avisa a tu usuario" + doc("supervisor"));
|
|
278
279
|
process.exit(0);
|
|
279
280
|
}
|
|
280
|
-
console.error("
|
|
281
|
+
console.error("karajan sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde." + doc("supervisor"));
|
|
281
282
|
process.exit(2);
|
|
282
283
|
}
|
|
283
284
|
state.tamper_blocks = 0;
|
|
@@ -296,7 +297,7 @@ process.stdin.on("end", () => {
|
|
|
296
297
|
}
|
|
297
298
|
const s = state.sessions?.[sid];
|
|
298
299
|
if (!s) {
|
|
299
|
-
if (claimsBlock) { console.error("
|
|
300
|
+
if (claimsBlock) { console.error("karajan sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock + doc("claims")); process.exit(2); }
|
|
300
301
|
if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
|
|
301
302
|
process.exit(0);
|
|
302
303
|
}
|
|
@@ -306,9 +307,9 @@ process.stdin.on("end", () => {
|
|
|
306
307
|
s.blocks = 0;
|
|
307
308
|
const notes = [];
|
|
308
309
|
if (claimsNote) notes.push(claimsNote);
|
|
309
|
-
if ((s.escapes || []).length) notes.push("
|
|
310
|
+
if ((s.escapes || []).length) notes.push("karajan sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
310
311
|
if ((s.closed_cards || []).length) {
|
|
311
|
-
notes.push("
|
|
312
|
+
notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
312
313
|
s.closed_cards = [];
|
|
313
314
|
}
|
|
314
315
|
save(state);
|
|
@@ -319,11 +320,11 @@ process.stdin.on("end", () => {
|
|
|
319
320
|
if (s.blocks > 3) {
|
|
320
321
|
(s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
|
|
321
322
|
save(state);
|
|
322
|
-
console.error("
|
|
323
|
+
console.error("karajan sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario" + doc("stop-gate"));
|
|
323
324
|
process.exit(0);
|
|
324
325
|
}
|
|
325
326
|
save(state);
|
|
326
|
-
console.error("
|
|
327
|
+
console.error("karajan sentinel: el turno NO puede terminar con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status" + doc("stop-gate"));
|
|
327
328
|
process.exit(2);
|
|
328
329
|
} catch { /* fail open */ }
|
|
329
330
|
process.exit(0);
|
|
@@ -340,7 +341,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
340
341
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
341
342
|
import { spawnSync } from "node:child_process";
|
|
342
343
|
import { homedir } from "node:os";
|
|
343
|
-
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
344
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
344
345
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
345
346
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
346
347
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -357,7 +358,7 @@ process.stdin.on("end", () => {
|
|
|
357
358
|
const target = input.file_path || input.notebook_path;
|
|
358
359
|
const relT = target ? relative(ROOT, String(target)).replaceAll("\\\\", "/") : "";
|
|
359
360
|
if (relT && PROTECTED.test(relT)) {
|
|
360
|
-
console.error("
|
|
361
|
+
console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
|
|
361
362
|
process.exit(2);
|
|
362
363
|
}
|
|
363
364
|
}
|
|
@@ -367,9 +368,23 @@ process.stdin.on("end", () => {
|
|
|
367
368
|
// substitution) cannot be resolved from command text: that vector is
|
|
368
369
|
// caught by the Stop gate's checksum verification, which blocks the turn.
|
|
369
370
|
if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\\\", "/"))) {
|
|
370
|
-
console.error("
|
|
371
|
+
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"));
|
|
371
372
|
process.exit(2);
|
|
372
373
|
}
|
|
374
|
+
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
375
|
+
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
376
|
+
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
377
|
+
// (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
|
|
378
|
+
// ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
|
|
379
|
+
// arriba: un env var lo pone tambien un agente — el supervisor no se
|
|
380
|
+
// apaga con interruptores al alcance del supervisado.
|
|
381
|
+
{
|
|
382
|
+
const kjCmd = String(input.command || "");
|
|
383
|
+
if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
|
|
384
|
+
console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
|
|
385
|
+
process.exit(2);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
373
388
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
374
389
|
// KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
|
|
375
390
|
// (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
|
|
@@ -411,7 +426,7 @@ process.stdin.on("end", () => {
|
|
|
411
426
|
if (bad === null) return true;
|
|
412
427
|
if (!escNoted.has(name)) {
|
|
413
428
|
escNoted.add(name);
|
|
414
|
-
console.error("
|
|
429
|
+
console.error("karajan sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)" + doc("escapes"));
|
|
415
430
|
}
|
|
416
431
|
return false;
|
|
417
432
|
};
|
|
@@ -530,7 +545,7 @@ process.stdin.on("end", () => {
|
|
|
530
545
|
if (problems.length > 0) {
|
|
531
546
|
if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
|
|
532
547
|
else {
|
|
533
|
-
console.error("
|
|
548
|
+
console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)" + doc("identity"));
|
|
534
549
|
process.exit(2);
|
|
535
550
|
}
|
|
536
551
|
}
|
|
@@ -553,7 +568,7 @@ process.stdin.on("end", () => {
|
|
|
553
568
|
};
|
|
554
569
|
const laneDeny = (what, lane) => {
|
|
555
570
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
|
|
556
|
-
console.error("
|
|
571
|
+
console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado." + doc("cross-lane"));
|
|
557
572
|
return true;
|
|
558
573
|
};
|
|
559
574
|
if (EDIT_TOOLS.includes(tool)) {
|
|
@@ -581,7 +596,7 @@ process.stdin.on("end", () => {
|
|
|
581
596
|
if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
|
|
582
597
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
583
598
|
else {
|
|
584
|
-
console.error("
|
|
599
|
+
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 (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
585
600
|
process.exit(2);
|
|
586
601
|
}
|
|
587
602
|
}
|
|
@@ -600,7 +615,7 @@ process.stdin.on("end", () => {
|
|
|
600
615
|
if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
|
|
601
616
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
602
617
|
else {
|
|
603
|
-
console.error("
|
|
618
|
+
console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
604
619
|
process.exit(2);
|
|
605
620
|
}
|
|
606
621
|
}
|
|
@@ -666,7 +681,7 @@ process.stdin.on("end", () => {
|
|
|
666
681
|
if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
|
|
667
682
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
668
683
|
else {
|
|
669
|
-
console.error("
|
|
684
|
+
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 (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
670
685
|
process.exit(2);
|
|
671
686
|
}
|
|
672
687
|
}
|
|
@@ -675,7 +690,7 @@ process.stdin.on("end", () => {
|
|
|
675
690
|
if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
676
691
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
677
692
|
else {
|
|
678
|
-
console.error("
|
|
693
|
+
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 (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
679
694
|
process.exit(2);
|
|
680
695
|
}
|
|
681
696
|
}
|
|
@@ -708,7 +723,7 @@ process.stdin.on("end", () => {
|
|
|
708
723
|
const actorRole = process.env.KJ_POLICY_ROLE || "coder";
|
|
709
724
|
const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
|
|
710
725
|
if (pres.error || pres.status === null) {
|
|
711
|
-
console.error("
|
|
726
|
+
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"));
|
|
712
727
|
process.exit(2);
|
|
713
728
|
}
|
|
714
729
|
if (pres.status === 2) {
|
|
@@ -717,13 +732,13 @@ process.stdin.on("end", () => {
|
|
|
717
732
|
const secure = !!(v && v.class === "security");
|
|
718
733
|
if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
719
734
|
else {
|
|
720
|
-
console.error("
|
|
735
|
+
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]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)") + doc("policy"));
|
|
721
736
|
process.exit(2);
|
|
722
737
|
}
|
|
723
738
|
} else if (pres.status !== 0) {
|
|
724
739
|
if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
725
740
|
else {
|
|
726
|
-
console.error("
|
|
741
|
+
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"));
|
|
727
742
|
process.exit(2);
|
|
728
743
|
}
|
|
729
744
|
}
|
|
@@ -746,7 +761,7 @@ process.stdin.on("end", () => {
|
|
|
746
761
|
if (hard.length && blockOn) {
|
|
747
762
|
if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
|
|
748
763
|
else {
|
|
749
|
-
console.error("
|
|
764
|
+
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) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)" + doc("steward"));
|
|
750
765
|
process.exit(2);
|
|
751
766
|
}
|
|
752
767
|
}
|
|
@@ -768,8 +783,8 @@ process.stdin.on("end", () => {
|
|
|
768
783
|
if (why) {
|
|
769
784
|
if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
|
|
770
785
|
console.error(why === "base"
|
|
771
|
-
? "
|
|
772
|
-
: "
|
|
786
|
+
? "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. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first")
|
|
787
|
+
: "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. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
|
|
773
788
|
process.exit(2);
|
|
774
789
|
}
|
|
775
790
|
}
|
|
@@ -787,7 +802,7 @@ process.stdin.on("end", () => {
|
|
|
787
802
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
788
803
|
if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
|
|
789
804
|
else {
|
|
790
|
-
console.error("
|
|
805
|
+
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"));
|
|
791
806
|
process.exit(2);
|
|
792
807
|
}
|
|
793
808
|
}
|
|
@@ -802,7 +817,7 @@ process.stdin.on("end", () => {
|
|
|
802
817
|
const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
|
|
803
818
|
const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
|
|
804
819
|
if (!g.error && g.status === 2) {
|
|
805
|
-
console.error("
|
|
820
|
+
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"));
|
|
806
821
|
process.exit(2);
|
|
807
822
|
}
|
|
808
823
|
}
|
|
@@ -813,7 +828,7 @@ process.stdin.on("end", () => {
|
|
|
813
828
|
if (res.status !== 0) {
|
|
814
829
|
let items = "";
|
|
815
830
|
try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
|
|
816
|
-
console.error("
|
|
831
|
+
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"));
|
|
817
832
|
process.exit(2);
|
|
818
833
|
}
|
|
819
834
|
} else if (PUSH.test(cmd)) {
|
|
@@ -827,7 +842,7 @@ process.stdin.on("end", () => {
|
|
|
827
842
|
v = violations({ ...sess, pending_moves: [] }, branchOf());
|
|
828
843
|
}
|
|
829
844
|
if (v.length) {
|
|
830
|
-
console.error("
|
|
845
|
+
console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
|
|
831
846
|
process.exit(2);
|
|
832
847
|
}
|
|
833
848
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0161 / ADR 0009 (opción A) — `kj harden --commit`, el cauce
|
|
3
|
+
* sancionado del supervisor: ACTO HUMANO que versiona la regeneración con
|
|
4
|
+
* provenance trackeada (versión, parámetros, sha256), sello en el acta y
|
|
5
|
+
* commit quirúrgico. El --no-verify es a conciencia: lo que salta en local
|
|
6
|
+
* lo re-verifica CI contra la provenance (pieza 3).
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
|
+
import { readIdentity } from "../identity/store.js";
|
|
15
|
+
|
|
16
|
+
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
17
|
+
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
18
|
+
|
|
19
|
+
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
20
|
+
|
|
21
|
+
// Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
|
|
22
|
+
// isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
|
|
23
|
+
// de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
|
|
24
|
+
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
25
|
+
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
|
|
26
|
+
const read = readProc || ((p) => {
|
|
27
|
+
const stat = readFileSync(`/proc/${p}/stat`, "utf8");
|
|
28
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
29
|
+
let cmd = "";
|
|
30
|
+
try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
|
|
31
|
+
return { ppid, cmd };
|
|
32
|
+
});
|
|
33
|
+
let cur = pid;
|
|
34
|
+
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
35
|
+
let info;
|
|
36
|
+
try { info = read(cur); } catch { return { agent: false, unknown: true }; }
|
|
37
|
+
if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
|
|
38
|
+
if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
|
|
39
|
+
cur = info.ppid;
|
|
40
|
+
}
|
|
41
|
+
return { agent: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
|
|
45
|
+
// atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
|
|
46
|
+
function defaultConfirm(nonce) {
|
|
47
|
+
process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
|
|
48
|
+
try {
|
|
49
|
+
const buf = Buffer.alloc(64);
|
|
50
|
+
const fd = openSync("/dev/tty", "r");
|
|
51
|
+
const n = readSync(fd, buf, 0, 64);
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
return buf.toString("utf8", 0, n).trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
60
|
+
export function supervisorDrift({ projectDir, gitFn }) {
|
|
61
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
62
|
+
// Un rename sale como "R old -> new" (catch de codex): ambas rutas son
|
|
63
|
+
// drift — la vieja se versiona como borrado y la nueva con su hash.
|
|
64
|
+
return run(["status", "--porcelain", "--", ".karajan/hooks"])
|
|
65
|
+
.split("\n")
|
|
66
|
+
.flatMap((l) => l.slice(3).trim().split(" -> "))
|
|
67
|
+
.filter((f) => f.startsWith(HOOKS_PREFIX));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @returns {{committed: boolean, reason?: string, files?: object[]}}
|
|
72
|
+
*/
|
|
73
|
+
export function commitSupervisorRegeneration({
|
|
74
|
+
projectDir,
|
|
75
|
+
kjVersion,
|
|
76
|
+
generation,
|
|
77
|
+
logger = console,
|
|
78
|
+
gitFn = null,
|
|
79
|
+
env = process.env,
|
|
80
|
+
tty = process.stdout.isTTY,
|
|
81
|
+
deps = {},
|
|
82
|
+
}) {
|
|
83
|
+
// El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
|
|
84
|
+
if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const anc = agentAncestry(deps.ancestry ?? {});
|
|
90
|
+
if (anc.agent) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
96
|
+
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
97
|
+
// Un alimentador ciego no conoce el código; automatizar su lectura exige
|
|
98
|
+
// un driver expect — premeditación inequívoca, no un descuido.
|
|
99
|
+
const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
|
|
100
|
+
const answer = (deps.confirm ?? defaultConfirm)(nonce);
|
|
101
|
+
if (answer !== nonce) {
|
|
102
|
+
throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
103
|
+
}
|
|
104
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
105
|
+
const files = supervisorDrift({ projectDir, gitFn: run });
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
logger.info?.("harden --commit: sin drift en el supervisor — nada que versionar");
|
|
108
|
+
return { committed: false, reason: "sin drift" };
|
|
109
|
+
}
|
|
110
|
+
// Borrados/renombrados también son drift (catch de codex): ausente ⇒ deleted.
|
|
111
|
+
const hashed = files.map((file) => {
|
|
112
|
+
const abs = join(projectDir, file);
|
|
113
|
+
return existsSync(abs) ? { file, sha256: sha256(abs) } : { file, deleted: true };
|
|
114
|
+
});
|
|
115
|
+
const who = readIdentity(projectDir);
|
|
116
|
+
const provenance = {
|
|
117
|
+
kj_version: kjVersion,
|
|
118
|
+
generated_at: new Date().toISOString(),
|
|
119
|
+
generation,
|
|
120
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
121
|
+
files: hashed,
|
|
122
|
+
};
|
|
123
|
+
writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
|
124
|
+
recordGateDecision(projectDir, {
|
|
125
|
+
decision: "exempt",
|
|
126
|
+
chokepoint: "supervisor",
|
|
127
|
+
kind: "supervisor-regeneration",
|
|
128
|
+
kj_version: kjVersion,
|
|
129
|
+
files: hashed,
|
|
130
|
+
who: provenance.who,
|
|
131
|
+
});
|
|
132
|
+
// Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
|
|
133
|
+
// sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
|
|
134
|
+
// supervisor versiona altas, cambios, borrados y renombrados por igual.
|
|
135
|
+
run(["add", "-A", "--", PROVENANCE_FILE, ".karajan/hooks"]);
|
|
136
|
+
run([
|
|
137
|
+
"commit",
|
|
138
|
+
"--no-verify",
|
|
139
|
+
"-m",
|
|
140
|
+
`chore(harden): supervisor regenerated and sealed by kj harden v${kjVersion}`,
|
|
141
|
+
"--",
|
|
142
|
+
PROVENANCE_FILE,
|
|
143
|
+
".karajan/hooks",
|
|
144
|
+
]);
|
|
145
|
+
logger.info?.(`harden --commit: ${files.length} fichero(s) de supervisor versionados con procedencia sellada`);
|
|
146
|
+
return { committed: true, files: hashed };
|
|
147
|
+
}
|
|
@@ -40,7 +40,9 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
|
40
40
|
" env:",
|
|
41
41
|
" BASE_REF: ${{ github.base_ref }}",
|
|
42
42
|
" run: |",
|
|
43
|
-
' msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"
|
|
43
|
+
' if ! msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"); then',
|
|
44
|
+
' echo "::error::cannot resolve origin/${BASE_REF}...HEAD — the scan did not run"; exit 1',
|
|
45
|
+
" fi",
|
|
44
46
|
" pat='co-authored-by:.*(claude|anthropic|openai|chatgpt|gpt-[0-9]|copilot|gemini|cursor|windsurf|codeium|deepseek)'",
|
|
45
47
|
" pat=\"$pat|(generated|written|authored|assisted|powered) (by|with|using) .*(claude|anthropic|openai|copilot|gemini)\"",
|
|
46
48
|
' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
|
package/src/policy/engine.js
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
export const SUPERVISOR_DEFAULTS = [
|
|
26
26
|
{
|
|
27
27
|
id: "defaults.supervisor.write",
|
|
28
|
-
|
|
28
|
+
// ADR 0009: la provenance del supervisor es superficie de supervisor —
|
|
29
|
+
// un agente no la escribe; solo kj harden --commit (humano) la produce.
|
|
30
|
+
pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\/|\.karajan\/supervisor-provenance\.json\b/,
|
|
29
31
|
message: "nombra ficheros del supervisor — solo el humano los modifica, fuera de la sesión",
|
|
30
32
|
},
|
|
31
33
|
];
|
package/src/policy/exceptions.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { userInfo } from "node:os";
|
|
9
|
+
import { homedir, userInfo } from "node:os";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { recordPolicyException as kernelRecord } from "@karajan-family/governance";
|
|
12
12
|
|
|
@@ -23,20 +23,25 @@ function defaultIdentity(projectDir) {
|
|
|
23
23
|
* reporta), jamás rompe la evaluación. Las puntuales no dan standing.
|
|
24
24
|
* @returns {{standing: object[], discarded: number}}
|
|
25
25
|
*/
|
|
26
|
-
export function loadStandingExceptions(projectDir) {
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
export function loadStandingExceptions(projectDir, { home } = {}) {
|
|
27
|
+
const proj = loadExceptionRecords(projectDir);
|
|
28
|
+
const glob = loadGlobalExceptionRecords({ home });
|
|
29
|
+
return {
|
|
30
|
+
standing: [...proj.records, ...glob.records].filter((rec) => rec.scopeKind === "permanente"),
|
|
31
|
+
discarded: proj.discarded + glob.discarded,
|
|
32
|
+
};
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* @returns {{records: object[], discarded: number}}
|
|
36
|
+
* Ruta del almacén GLOBAL (~/.karajan): excepciones de la máquina, no de un
|
|
37
|
+
* proyecto. `home` es inyectable SOLO para tests.
|
|
35
38
|
*/
|
|
36
|
-
export
|
|
39
|
+
export const globalExceptionsFile = ({ home } = {}) => join(home ?? homedir(), ".karajan", "policy-exceptions.jsonl");
|
|
40
|
+
|
|
41
|
+
function readRecordsAt(file, origin) {
|
|
37
42
|
let raw;
|
|
38
43
|
try {
|
|
39
|
-
raw = readFileSync(
|
|
44
|
+
raw = readFileSync(file, "utf8");
|
|
40
45
|
} catch {
|
|
41
46
|
return { records: [], discarded: 0 };
|
|
42
47
|
}
|
|
@@ -49,7 +54,9 @@ export function loadExceptionRecords(projectDir) {
|
|
|
49
54
|
// JSON válido pero no-objeto (null, número…) es tan corrupto como el
|
|
50
55
|
// que no parsea: se descarta contando (catch de codex, explícito).
|
|
51
56
|
if (typeof rec !== "object" || rec === null) discarded += 1;
|
|
52
|
-
|
|
57
|
+
// Procedencia FÍSICA (KJC-TSK-0813): el origin es el almacén del que
|
|
58
|
+
// se LEYÓ la línea, pisando lo que la línea declare — no se miente.
|
|
59
|
+
else records.push({ ...rec, origin });
|
|
53
60
|
} catch {
|
|
54
61
|
discarded += 1;
|
|
55
62
|
}
|
|
@@ -57,14 +64,43 @@ export function loadExceptionRecords(projectDir) {
|
|
|
57
64
|
return { records, discarded };
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
/**
|
|
68
|
+
* TODOS los registros del jsonl (permanentes y puntuales) con el mismo parse
|
|
69
|
+
* tolerante — el informe (PL-E) cuenta también las puntuales.
|
|
70
|
+
* @returns {{records: object[], discarded: number}}
|
|
71
|
+
*/
|
|
72
|
+
export function loadExceptionRecords(projectDir) {
|
|
73
|
+
return readRecordsAt(join(projectDir, ".karajan", "policy-exceptions.jsonl"), "project");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {{records: object[], discarded: number}} */
|
|
77
|
+
export function loadGlobalExceptionRecords({ home } = {}) {
|
|
78
|
+
return readRecordsAt(globalExceptionsFile({ home }), "global");
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
function defaultAppend(projectDir, line) {
|
|
61
82
|
const dir = join(projectDir, ".karajan");
|
|
62
83
|
mkdirSync(dir, { recursive: true });
|
|
63
84
|
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
64
85
|
}
|
|
65
86
|
|
|
87
|
+
const globalAppend = (home) => (_projectDir, line) => {
|
|
88
|
+
const dir = join(home ?? homedir(), ".karajan");
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
91
|
+
};
|
|
92
|
+
|
|
66
93
|
/** @returns {object} the recorded entry (with ts + who resolved). */
|
|
67
|
-
export function recordPolicyException({ projectDir, entry, deps = {} }) {
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
export function recordPolicyException({ projectDir, entry, deps = {}, scope = "project" }) {
|
|
95
|
+
if (scope !== "project" && scope !== "global") {
|
|
96
|
+
throw new TypeError(`recordPolicyException: scope "${scope}" no existe — solo project o global`);
|
|
97
|
+
}
|
|
98
|
+
// Un global puntual NO existe: lo puntual liga al hash de un artefacto de
|
|
99
|
+
// UN proyecto; el ámbito global solo admite permanentes con caducidad.
|
|
100
|
+
if (scope === "global" && (entry?.scopeKind !== "permanente" || !entry?.expiresAt)) {
|
|
101
|
+
throw new TypeError("recordPolicyException: el ámbito global exige scopeKind permanente con expiresAt — un global puntual no existe");
|
|
102
|
+
}
|
|
103
|
+
const { home, append = scope === "global" ? globalAppend(home) : defaultAppend, identity = defaultIdentity } = deps;
|
|
104
|
+
// El origin escrito es informativo: la carga re-estampa el FÍSICO igualmente.
|
|
105
|
+
return kernelRecord({ projectDir, entry: { ...entry, origin: scope }, deps: { append, identity } });
|
|
70
106
|
}
|