karajan-code 4.39.0 → 4.41.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/src/audit/ai-slop-findings.js +4 -2
- package/src/audit/circular-deps.js +8 -7
- package/src/audit/webperf-input.js +3 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/commands/bootstrap.js +12 -41
- package/src/commands/env.js +8 -0
- package/src/commands/init.js +6 -2
- package/src/commands/rules-approve.js +63 -0
- package/src/commands/rules-compile.js +117 -0
- package/src/commands/rules-decide.js +52 -0
- package/src/commands/rules-review.js +57 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/environment/contract-commit.js +79 -0
- package/src/harden/config-templates.js +3 -0
- package/src/harden/human-act.js +70 -0
- package/src/harden/phone-sign.js +26 -0
- package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
- package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
- package/src/harden/sentinel/sentinel-discard.mjs +4 -2
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +17 -0
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +23 -2
- package/src/harden/supervisor-commit.js +13 -56
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/sovereignty-guard.js +16 -15
- package/src/orchestrator/preflight-checks.js +4 -3
- package/src/policy/supervisor-verify.js +12 -2
- package/src/privacy/scan.js +7 -0
- package/src/review/gate-gitignore.js +8 -0
- package/src/rules/approval-view.js +54 -0
- package/src/rules/compiled.js +111 -0
- package/src/rules/coverage.js +23 -0
- package/src/rules/evaluate.js +60 -0
- package/src/rules/inventory.js +118 -0
- package/src/sonar/config-resolver.js +19 -1
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
|
@@ -106,5 +106,22 @@ export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) =
|
|
|
106
106
|
return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
|
|
107
107
|
}));
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* KJC-BUG-0268: the words of a simple command without its redirections (2>&1,
|
|
111
|
+
* >file, < file, >> log...) and the operand a detached operator takes. A
|
|
112
|
+
* redirection is where output goes, never an argument of the command.
|
|
113
|
+
* @param {string[]} words
|
|
114
|
+
*/
|
|
115
|
+
export const withoutRedirections = (words) => {
|
|
116
|
+
const rest = [];
|
|
117
|
+
let operand = false; // the previous word was a bare operator: this one is its target
|
|
118
|
+
for (const word of words) {
|
|
119
|
+
if (operand) operand = false;
|
|
120
|
+
else if (!/^\d*[<>]/.test(word)) rest.push(word);
|
|
121
|
+
else operand = /^\d*(<{1,3}|>>?|>[|&])$/.test(word);
|
|
122
|
+
}
|
|
123
|
+
return rest;
|
|
124
|
+
};
|
|
125
|
+
|
|
109
126
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
110
127
|
export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// kj sentinel SessionStart hook (KJC-TSK-0918, ADR 0014), managed by `kj harden`.
|
|
3
3
|
// A compaction takes exactly what the agent needs most: the rules it read at the
|
|
4
4
|
// start. After a compaction or a resume, this gives them back, short, with the
|
|
5
|
-
// state of the session. A new session gets
|
|
5
|
+
// state of the session. A new session gets no rules: CLAUDE.md already brings them.
|
|
6
6
|
// Copied byte for byte into .karajan/harness; never fails a session (exit 0).
|
|
7
7
|
import process from "node:process";
|
|
8
|
-
import {
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { CARD, ROOT, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
|
|
10
|
+
import { rulesDriftNotice } from "./sentinel-rules.mjs";
|
|
9
11
|
|
|
10
12
|
const RULES = [
|
|
11
13
|
"1. Karajan gobierna y se le obedece: no cambies políticas, configuración de gates ni exclusiones para pasar un gate. Si uno parece injusto, díselo a tu usuario o usa kj report-issue.",
|
|
@@ -24,13 +26,20 @@ process.stdin.on("data", (d) => { raw += d; });
|
|
|
24
26
|
process.stdin.on("end", () => {
|
|
25
27
|
try {
|
|
26
28
|
const { session_id: sid = "default", source } = JSON.parse(raw || "{}");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// KJC-TSK-0949: an MD changed and its compiled rules fell behind. Said at
|
|
30
|
+
// every start, a new session included: that one gets this line and nothing else.
|
|
31
|
+
const drift = rulesDriftNotice({ root: ROOT, run: spawnSync });
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (source === "compact" || source === "resume") {
|
|
34
|
+
const branch = branchOf() || "?";
|
|
35
|
+
const card = CARD.exec(branch)?.[0]?.toUpperCase() ?? "ninguna en la rama";
|
|
36
|
+
const pending = pendingMoves(load().sessions?.[sid]).map((p) => `${p.card ?? "?"} (PR #${p.pr})`);
|
|
37
|
+
const unmoved = pending.length ? "; cards mergeadas sin mover: " + pending.join(", ") : "";
|
|
38
|
+
lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
|
|
39
|
+
}
|
|
40
|
+
if (drift) lines.push(`Karajan: ${drift}`);
|
|
41
|
+
if (lines.length === 0) process.exit(0);
|
|
42
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
|
|
34
43
|
} catch { /* never fails a session */ }
|
|
35
44
|
process.exit(0);
|
|
36
45
|
});
|
|
@@ -92,11 +92,22 @@ export const boardGate = (sid, pend) => {
|
|
|
92
92
|
}
|
|
93
93
|
return { blocking: pend, carried: [] };
|
|
94
94
|
};
|
|
95
|
+
// KJC-BUG-0265: on the base branch what counts is work still there (the session's
|
|
96
|
+
// files dirty, or local commits the remote does not have), not the ledger: after a
|
|
97
|
+
// merged PR and a sync of main the ledger stays full while the base holds nothing.
|
|
98
|
+
// Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
|
|
99
|
+
const baseHoldsWork = (files) => {
|
|
100
|
+
if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
|
|
101
|
+
try {
|
|
102
|
+
if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
|
|
103
|
+
return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
|
|
104
|
+
} catch { return true; }
|
|
105
|
+
};
|
|
95
106
|
export const violations = (s, branch, pending = pendingMoves(s)) => {
|
|
96
107
|
const v = pending.map(pendingText);
|
|
97
108
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
98
109
|
if (bootstrapPhase()) return v;
|
|
99
|
-
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
110
|
+
if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
|
|
100
111
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
101
112
|
if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
102
113
|
return v;
|
|
@@ -492,7 +503,9 @@ process.stdin.on("end", () => {
|
|
|
492
503
|
}
|
|
493
504
|
// KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
|
|
494
505
|
// like the supervisor: a session does not loosen the rules that govern it.
|
|
495
|
-
|
|
506
|
+
// .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
|
|
507
|
+
// .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
|
|
508
|
+
if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
|
|
496
509
|
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"));
|
|
497
510
|
process.exit(2);
|
|
498
511
|
}
|
|
@@ -1127,6 +1140,9 @@ const SCRIPT_BODIES = {
|
|
|
1127
1140
|
"sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
|
|
1128
1141
|
"sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
|
|
1129
1142
|
"sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
|
|
1143
|
+
// KJC-TSK-0946 (ADR 0016): the rules gate and its hook, for every tool call.
|
|
1144
|
+
"sentinel-rules.mjs": readFileSync(new URL("./sentinel/sentinel-rules.mjs", import.meta.url), "utf8"),
|
|
1145
|
+
"pretooluse-rules.mjs": readFileSync(new URL("./sentinel/pretooluse-rules.mjs", import.meta.url), "utf8"),
|
|
1130
1146
|
};
|
|
1131
1147
|
|
|
1132
1148
|
/**
|
|
@@ -1316,6 +1332,9 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1316
1332
|
entries: [
|
|
1317
1333
|
{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
|
|
1318
1334
|
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
|
|
1335
|
+
// KJC-TSK-0946 (ADR 0016): no matcher. The rules of the MD files are checked
|
|
1336
|
+
// before EVERY tool call, MCP ones included.
|
|
1337
|
+
{ event: "PreToolUse", script: "pretooluse-rules.mjs" },
|
|
1319
1338
|
{ event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
|
|
1320
1339
|
// KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
|
|
1321
1340
|
// update_card) are observed AFTER the fact — found live: with only the
|
|
@@ -1329,6 +1348,8 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1329
1348
|
// KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
|
|
1330
1349
|
{ event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
|
|
1331
1350
|
{ event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
|
|
1351
|
+
// KJC-TSK-0949: a new session hears of MD rules with no gate, and nothing else.
|
|
1352
|
+
{ event: "SessionStart", matcher: "startup", script: "sessionstart.mjs" },
|
|
1332
1353
|
],
|
|
1333
1354
|
});
|
|
1334
1355
|
return { scripts: [lib, post, stop, pre], wired, deferred };
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import {
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
14
|
import { readIdentity } from "../identity/store.js";
|
|
15
|
+
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
16
|
+
import { confirmHuman, refuseAgentSession } from "./human-act.js";
|
|
15
17
|
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
16
18
|
|
|
17
19
|
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
@@ -26,43 +28,7 @@ const HARNESS_PREFIX = ".karajan/harness/";
|
|
|
26
28
|
|
|
27
29
|
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
// isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
|
|
31
|
-
// de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
|
|
32
|
-
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
33
|
-
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
|
|
34
|
-
const read = readProc || ((p) => {
|
|
35
|
-
const stat = readFileSync(`/proc/${p}/stat`, "utf8");
|
|
36
|
-
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
37
|
-
let cmd = "";
|
|
38
|
-
try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
|
|
39
|
-
return { ppid, cmd };
|
|
40
|
-
});
|
|
41
|
-
let cur = pid;
|
|
42
|
-
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
43
|
-
let info;
|
|
44
|
-
try { info = read(cur); } catch { return { agent: false, unknown: true }; }
|
|
45
|
-
if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
|
|
46
|
-
if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
|
|
47
|
-
cur = info.ppid;
|
|
48
|
-
}
|
|
49
|
-
return { agent: false };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
|
|
53
|
-
// atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
|
|
54
|
-
function defaultConfirm(nonce) {
|
|
55
|
-
process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
|
|
56
|
-
try {
|
|
57
|
-
const buf = Buffer.alloc(64);
|
|
58
|
-
const fd = openSync("/dev/tty", "r");
|
|
59
|
-
const n = readSync(fd, buf, 0, 64);
|
|
60
|
-
closeSync(fd);
|
|
61
|
-
return buf.toString("utf8", 0, n).trim();
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
31
|
+
const ACT = "harden --commit";
|
|
66
32
|
|
|
67
33
|
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
68
34
|
/**
|
|
@@ -106,17 +72,8 @@ export async function commitSupervisorRegeneration({
|
|
|
106
72
|
deps = {},
|
|
107
73
|
}) {
|
|
108
74
|
// El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
const anc = agentAncestry(deps.ancestry ?? {});
|
|
115
|
-
if (anc.agent) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`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)`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
75
|
+
// Capas 1 a 3 (entorno, tty y ascendencia de procesos): src/harden/human-act.js.
|
|
76
|
+
refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
|
|
120
77
|
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
121
78
|
// KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
|
|
122
79
|
// un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
|
|
@@ -129,13 +86,7 @@ export async function commitSupervisorRegeneration({
|
|
|
129
86
|
}
|
|
130
87
|
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
131
88
|
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
132
|
-
|
|
133
|
-
// un driver expect — premeditación inequívoca, no un descuido.
|
|
134
|
-
const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
|
|
135
|
-
const answer = (deps.confirm ?? defaultConfirm)(nonce);
|
|
136
|
-
if (answer !== nonce) {
|
|
137
|
-
throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
138
|
-
}
|
|
89
|
+
confirmHuman(ACT, deps.confirm);
|
|
139
90
|
const drift = supervisorDrift({ projectDir, gitFn: run });
|
|
140
91
|
// La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
|
|
141
92
|
// en el primer estreno real: un sello parcial pisaba al anterior y dejaba
|
|
@@ -207,6 +158,12 @@ export async function commitSupervisorRegeneration({
|
|
|
207
158
|
files: hashed,
|
|
208
159
|
who: provenance.who,
|
|
209
160
|
});
|
|
161
|
+
// KJC-BUG-0264: un bloque .karajan antiguo en .gitignore dejaba la procedencia
|
|
162
|
+
// fuera de git y el add fallaba. Se completa antes el bloque canónico (la misma
|
|
163
|
+
// migración que kj review --install-gate); el cambio queda para una PR normal.
|
|
164
|
+
if ((await ensureGateTrackable(projectDir)).changed) {
|
|
165
|
+
logger.info?.("harden --commit: .gitignore completado con el bloque canónico de .karajan (la procedencia viaja con el repo); súbelo en una PR");
|
|
166
|
+
}
|
|
210
167
|
// Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
|
|
211
168
|
// sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
|
|
212
169
|
// supervisor versiona altas, cambios, borrados y renombrados por igual.
|
|
@@ -132,6 +132,11 @@ export async function handleRunDirect(a, server, extra) {
|
|
|
132
132
|
|
|
133
133
|
const projectDir = await resolveProjectDir(server, a.projectDir);
|
|
134
134
|
const runLog = createRunLog(projectDir);
|
|
135
|
+
// KJC-BUG-0250: two starts racing past the session check, only one takes the lock.
|
|
136
|
+
if (!runLog.owned) {
|
|
137
|
+
runLog.close();
|
|
138
|
+
return failPayload("A pipeline is already running for this project. Wait for it to complete or use kj_status to check progress.");
|
|
139
|
+
}
|
|
135
140
|
runLog.logText(`[kj_run] started — task="${a.task.slice(0, 80)}..."`);
|
|
136
141
|
|
|
137
142
|
const emitter = new EventEmitter();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { isPidAlive } from "../utils/run-log.js";
|
|
10
11
|
|
|
11
12
|
/** Parameters that the host AI is allowed to pass through without restriction. */
|
|
12
13
|
const ALLOWED_PARAMS = new Set([
|
|
@@ -31,33 +32,33 @@ const ALLOWED_PARAMS = new Set([
|
|
|
31
32
|
|
|
32
33
|
const MIN_ITERATIONS = 1;
|
|
33
34
|
const MAX_ITERATIONS = 10;
|
|
34
|
-
|
|
35
|
+
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Check if a pipeline is already running for this project.
|
|
38
|
-
*
|
|
39
|
+
* KJC-BUG-0250 (#1897, #1892): the fact is the run lock (.kj/run.lock) held by
|
|
40
|
+
* a live process. The old signal, a recent write to run.log, also fired after
|
|
41
|
+
* a run that failed in preflight or any short command, blocking the next run.
|
|
39
42
|
*
|
|
40
43
|
* @param {string} projectDir - Project root directory
|
|
41
44
|
* @returns {{ active: boolean, message?: string }}
|
|
42
45
|
*/
|
|
43
46
|
export function checkActiveSession(projectDir) {
|
|
44
47
|
if (!projectDir) return { active: false };
|
|
45
|
-
|
|
48
|
+
let holder;
|
|
46
49
|
try {
|
|
47
|
-
|
|
48
|
-
const ageMs = Date.now() - stat.mtimeMs;
|
|
49
|
-
if (ageMs < ACTIVE_SESSION_THRESHOLD_MS) {
|
|
50
|
-
return {
|
|
51
|
-
active: true,
|
|
52
|
-
message:
|
|
53
|
-
"A pipeline is already running for this project. " +
|
|
54
|
-
"Wait for it to complete or use kj_status to check progress.",
|
|
55
|
-
};
|
|
56
|
-
}
|
|
50
|
+
holder = JSON.parse(fs.readFileSync(path.join(projectDir, ".kj", "run.lock"), "utf8"));
|
|
57
51
|
} catch {
|
|
58
|
-
|
|
52
|
+
return { active: false }; // no lock, or unreadable: nothing holds the project
|
|
59
53
|
}
|
|
60
|
-
return { active: false };
|
|
54
|
+
if (!isPidAlive(holder?.pid)) return { active: false }; // stale: its process is gone
|
|
55
|
+
const since = holder.startedAt ? ", since " + holder.startedAt : "";
|
|
56
|
+
return {
|
|
57
|
+
active: true,
|
|
58
|
+
message:
|
|
59
|
+
`A pipeline is already running for this project (pid ${holder.pid}${since}). ` +
|
|
60
|
+
"Wait for it to complete or use kj_status to check progress.",
|
|
61
|
+
};
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/**
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveSonarHost,
|
|
20
20
|
resolveSonarTokenAsync,
|
|
21
21
|
resolveSonarCredentials,
|
|
22
|
+
sonarTokenSources,
|
|
22
23
|
} from "../sonar/config-resolver.js";
|
|
23
24
|
import { saveSonarToken } from "../sonar/credentials.js";
|
|
24
25
|
import { withDocLink } from "../utils/doc-links.js";
|
|
@@ -106,7 +107,7 @@ async function checkSonarAuth(config) {
|
|
|
106
107
|
const { user: adminUser, passwords } = await resolveSonarCredentials(config);
|
|
107
108
|
|
|
108
109
|
if (!adminUser || passwords.length === 0) {
|
|
109
|
-
return { name: "sonar-auth", ok: false, detail:
|
|
110
|
+
return { name: "sonar-auth", ok: false, detail: `No Sonar token or admin credentials found. Looked in: ${sonarTokenSources()}. With a custom KARAJAN_HOME/KJ_HOME, the token must be there (or pass kjHome).` };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
for (const password of passwords) {
|
|
@@ -327,8 +328,8 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
|
|
|
327
328
|
result.ok = false;
|
|
328
329
|
result.errors.push({
|
|
329
330
|
check: "sonar-auth",
|
|
330
|
-
message:
|
|
331
|
-
fix: withDocLink("Fix: run 'kj init' to configure it, or set KJ_SONAR_TOKEN env var, or add sonarqube.token to
|
|
331
|
+
message: `SonarQube is running but no authentication token was found. Looked in: ${sonarTokenSources()}.`,
|
|
332
|
+
fix: withDocLink("Fix: run 'kj init' to configure it, or set KJ_SONAR_TOKEN env var, or add sonarqube.token to the kj.config.yml of the active Karajan home.", "sonar_token")
|
|
332
333
|
});
|
|
333
334
|
logger.error("Preflight: Sonar auth failed");
|
|
334
335
|
}
|
|
@@ -9,6 +9,7 @@ import { lstatSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { resolve, sep } from "node:path";
|
|
10
10
|
|
|
11
11
|
import { renderCanonicalHook } from "../harden/harden-engine.js";
|
|
12
|
+
import { provenanceSignature } from "../harden/phone-sign.js";
|
|
12
13
|
import { canonicalHarnessBody } from "../harden/sentinel-hooks.js";
|
|
13
14
|
import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
14
15
|
|
|
@@ -35,6 +36,9 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
35
36
|
if (gen.globalHooksDir != null && !SAFE_DIR.test(gen.globalHooksDir)) {
|
|
36
37
|
return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
|
|
37
38
|
}
|
|
39
|
+
// KJC-TSK-0964 (ADR 0018): with a roster, an unsigned or mis-signed seal backs nothing.
|
|
40
|
+
const signed = provenanceSignature({ projectDir, provenance: prov });
|
|
41
|
+
if (!signed.ok) return { files: new Set(), reason: signed.reason };
|
|
38
42
|
const ok = new Set();
|
|
39
43
|
const unjudgeable = new Set();
|
|
40
44
|
// KJC-BUG-0259 (user's decision, option A): history is judged with the kj of
|
|
@@ -89,7 +93,7 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
89
93
|
// maquina que sello, porque los guardias no viajan con el repo (0212).
|
|
90
94
|
const judged = (e) => ok.has(e.file) || unjudgeable.has(e.file);
|
|
91
95
|
const complete = ok.size > 0 && entries.every((e) => typeof e?.file === "string" && judged(e));
|
|
92
|
-
return { files: ok, complete, unjudgeable };
|
|
96
|
+
return { files: ok, complete, unjudgeable, signed: signed.required };
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/**
|
|
@@ -109,9 +113,15 @@ export function liftSealedSupervisorViolations({ projectDir, violations, tracked
|
|
|
109
113
|
&& (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
|
|
110
114
|
const kept = violations.filter((v) => !liftable(v));
|
|
111
115
|
const unjudged = sealed.unjudgeable?.size ?? 0;
|
|
116
|
+
const notes = [
|
|
117
|
+
...(unjudged > 0 ? [`${unjudged} guardia(s) del sello no existen en este checkout y no se han podido comprobar aquí`] : []),
|
|
118
|
+
// KJC-TSK-0964: what the signature did or did not prove is said, never assumed.
|
|
119
|
+
...(sealed.signed === false ? ["proyecto sin padrón de firmantes: el sello no lleva firma verificable y su garantía es solo local"] : []),
|
|
120
|
+
...(kept.length === violations.length && sealed.reason ? [sealed.reason] : []),
|
|
121
|
+
];
|
|
112
122
|
return {
|
|
113
123
|
violations: kept,
|
|
114
124
|
lifted: violations.length - kept.length,
|
|
115
|
-
...(
|
|
125
|
+
...(notes.length > 0 ? { note: notes.join("; ") } : {}),
|
|
116
126
|
};
|
|
117
127
|
}
|
package/src/privacy/scan.js
CHANGED
|
@@ -91,6 +91,13 @@ const CONTEXT_DISCARDS = [
|
|
|
91
91
|
// (Visa 4, Mastercard 2/5, Amex 3, Discover 6), so a card number cannot
|
|
92
92
|
// occupy that slot at any length.
|
|
93
93
|
{ type: "tracker-id", re: /\b[A-Z]{2,6}-1[6-9]\d{11}-\d{1,4}\b/g },
|
|
94
|
+
// KJC-BUG-0270: a rule id of the inventory (ADR 0016) is `R-` and 10 hex chars
|
|
95
|
+
// of a hash, and .karajan/rules.yml is versioned: an id with a long run of
|
|
96
|
+
// digits warned as a phone on every commit. Only the WHOLE shape is discarded:
|
|
97
|
+
// exactly ten lowercase hex chars with at least one letter. An all-digit id
|
|
98
|
+
// (about 1 in 100) still warns, because nothing tells it apart from a phone
|
|
99
|
+
// written after "R-"; the allow list is the way out for that one.
|
|
100
|
+
{ type: "rule-id", re: /\bR-(?=\d*[a-f])[0-9a-f]{10}\b/g },
|
|
94
101
|
];
|
|
95
102
|
|
|
96
103
|
/**
|
|
@@ -32,6 +32,14 @@ export const CONTRACT_BLOCK = [
|
|
|
32
32
|
// ADR 0009 (KJC-BUG-0161): la procedencia del supervisor VIAJA con el
|
|
33
33
|
// repo — es lo que CI verifica. Cazado en el primer harden --commit real.
|
|
34
34
|
"!.karajan/supervisor-provenance.json",
|
|
35
|
+
// KJC-BUG-0269: the policy is what CI evaluates; its anchor and the signers
|
|
36
|
+
// roll are what CI verifies. Ignored, a new project ran every clone with none.
|
|
37
|
+
"!.karajan/policy.yml",
|
|
38
|
+
"!.karajan/policy-anchor.json",
|
|
39
|
+
"!.karajan/supervisor-signers.json",
|
|
40
|
+
// ADR 0016 (KJC-TSK-0947): the rules of the MD files compiled into gates are
|
|
41
|
+
// governance the whole team inherits, like the hooks.
|
|
42
|
+
"!.karajan/rules.yml",
|
|
35
43
|
];
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the human reads before approving compiled rules (KJC-TSK-0962, MDR-F3,
|
|
3
|
+
* ADR 0017), in the order of what can hurt. The agent the rules will watch wrote
|
|
4
|
+
* the proposal, so a weak compilation must not hide among a hundred rules:
|
|
5
|
+
*
|
|
6
|
+
* 1. what loses or changes force against what is approved today;
|
|
7
|
+
* 2. what has no effective gate (judgment and out-of-scope block nothing);
|
|
8
|
+
* 3. the deterministic gates;
|
|
9
|
+
* 4. the approved rules that leave.
|
|
10
|
+
*/
|
|
11
|
+
const GATE = "deterministic";
|
|
12
|
+
|
|
13
|
+
const compiled = (rule) => {
|
|
14
|
+
if (rule.kind === "out-of-scope") return `reason: ${rule.reason}`;
|
|
15
|
+
// KJC-TSK-0973: why a judgment rule takes no condition is read next to it.
|
|
16
|
+
const why = rule.reason ? " (" + rule.reason + ")" : "";
|
|
17
|
+
return `when: ${JSON.stringify(rule.when)}${why}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** How a proposed rule weakens or changes the approved one, or "" when it does not. */
|
|
21
|
+
function weakening(rule, approved) {
|
|
22
|
+
if (approved?.kind !== GATE) return "";
|
|
23
|
+
if (rule.kind !== GATE) return `was ${GATE}, now ${rule.kind}`;
|
|
24
|
+
return JSON.stringify(rule.when) === JSON.stringify(approved.when) ? "" : "condition changed";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object[]} proposed the rules of a checked proposal
|
|
29
|
+
* @param {object[]} current the rules approved today (both files)
|
|
30
|
+
* @param {Set<string>} local ids that go to the unversioned file
|
|
31
|
+
* @param {{versioned: string, unversioned: string}} files the two rules files, as shown
|
|
32
|
+
* @returns {string[]} lines, ready to print
|
|
33
|
+
*/
|
|
34
|
+
export function approvalView(proposed, current, local, files) {
|
|
35
|
+
const approved = new Map(current.map((rule) => [rule.id, rule]));
|
|
36
|
+
const where = (rule) => (local.has(rule.id) ? `${files.unversioned}, not versioned` : files.versioned);
|
|
37
|
+
const shown = (rule) => [`${rule.id} ${rule.kind} → ${where(rule)}`, ` ${rule.text}`, ` ${compiled(rule)}`];
|
|
38
|
+
const weakened = proposed.map((rule) => ({ rule, how: weakening(rule, approved.get(rule.id)) })).filter(({ how }) => how);
|
|
39
|
+
const seen = new Set(weakened.map(({ rule }) => rule.id));
|
|
40
|
+
const rest = proposed.filter((rule) => !seen.has(rule.id));
|
|
41
|
+
const ungated = rest.filter((rule) => rule.kind !== GATE);
|
|
42
|
+
const gates = rest.filter((rule) => rule.kind === GATE);
|
|
43
|
+
const ids = new Set(proposed.map((rule) => rule.id));
|
|
44
|
+
const leaving = current.filter((rule) => !ids.has(rule.id));
|
|
45
|
+
const block = (title, lines) => (lines.length ? [title, ...lines, ""] : []);
|
|
46
|
+
return [
|
|
47
|
+
...block(`⚠ ${weakened.length} rule(s) LOSE or CHANGE force against what is approved today:`, weakened.flatMap(({ rule, how }) => [
|
|
48
|
+
`${rule.id} ${how} → ${where(rule)}`, ` ${rule.text}`, ` was: ${compiled(approved.get(rule.id))}`, ` now: ${compiled(rule)}`,
|
|
49
|
+
])),
|
|
50
|
+
...block(`${ungated.length} rule(s) with NO effective gate: nothing is blocked on them.`, ungated.flatMap(shown)),
|
|
51
|
+
...block(`${gates.length} ${GATE} gate(s):`, gates.flatMap(shown)),
|
|
52
|
+
...block(`${leaving.length} rule(s) LEAVE:`, leaving.map((rule) => `${rule.id} ${rule.text ?? ""}`)),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled rules, the format (KJC-TSK-0938, MDR-B, ADR 0016). A rule from the MD
|
|
3
|
+
* files, compiled into a condition over a tool call: which tool, which arguments.
|
|
4
|
+
* The operators are a closed set, so a rule is auditable and runs with no model.
|
|
5
|
+
* `judgment` rules carry no condition to evaluate: the judge reads them.
|
|
6
|
+
* `out-of-scope` rules are no gate at all: a decision, with its reason.
|
|
7
|
+
*
|
|
8
|
+
* An invalid file yields no rules (a rule that lies is worse than none).
|
|
9
|
+
*/
|
|
10
|
+
import yaml from "js-yaml";
|
|
11
|
+
|
|
12
|
+
const ID = /^R-[0-9a-f]{10}$/;
|
|
13
|
+
const OUT_OF_SCOPE = "out-of-scope";
|
|
14
|
+
const KINDS = ["deterministic", "judgment", OUT_OF_SCOPE];
|
|
15
|
+
const OPERATORS = ["equals", "in", "matches", "exists", "gt", "lt"];
|
|
16
|
+
|
|
17
|
+
export const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
/** The tool globs a rule names (`when.tool` is one glob or a list). */
|
|
19
|
+
export const toolsOf = (when) => [when?.tool].flat().filter((t) => typeof t === "string" && t);
|
|
20
|
+
|
|
21
|
+
// The format is closed at every level: a key nobody reads would be a condition
|
|
22
|
+
// the author believes in and the evaluator ignores.
|
|
23
|
+
const RULE_KEYS = ["id", "source", "text", "kind", "when", "message", "examples", "reason"];
|
|
24
|
+
const GATE_KEYS = ["when", "message", "examples"];
|
|
25
|
+
const WHEN_KEYS = ["tool", "all", "any"];
|
|
26
|
+
const CONDITION_KEYS = ["arg", "days_from", ...OPERATORS];
|
|
27
|
+
const unknownKeys = (obj, known) => (isObject(obj) ? Object.keys(obj).filter((k) => !known.includes(k)) : []);
|
|
28
|
+
|
|
29
|
+
const isRegex = (source) => {
|
|
30
|
+
try { new RegExp(source); return typeof source === "string"; } catch { return false; }
|
|
31
|
+
};
|
|
32
|
+
/** What each operator takes; anything else invalidates the condition. */
|
|
33
|
+
const OPERAND = {
|
|
34
|
+
equals: () => true,
|
|
35
|
+
in: Array.isArray,
|
|
36
|
+
matches: isRegex,
|
|
37
|
+
exists: (v) => typeof v === "boolean",
|
|
38
|
+
gt: Number.isFinite,
|
|
39
|
+
lt: Number.isFinite,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A dotted path into the tool input: non-empty segments (`updates.status`). */
|
|
43
|
+
const isPath = (v) => typeof v === "string" && /^[\w-]+(\.[\w-]+)*$/.test(v);
|
|
44
|
+
|
|
45
|
+
function conditionErrors(cond) {
|
|
46
|
+
if (!isObject(cond) || !isPath(cond.arg)) return ["a condition needs an `arg` (dotted path into the tool input)"];
|
|
47
|
+
const where = `condition on ${cond.arg}`;
|
|
48
|
+
const unknown = unknownKeys(cond, CONDITION_KEYS);
|
|
49
|
+
if (unknown.length) return [`${where}: unknown key ${unknown.join(", ")} (operators: ${OPERATORS.join(", ")})`];
|
|
50
|
+
const ops = OPERATORS.filter((op) => op in cond);
|
|
51
|
+
if (ops.length !== 1) return [`${where}: exactly one operator of ${OPERATORS.join(", ")}`];
|
|
52
|
+
const [op] = ops;
|
|
53
|
+
if (!OPERAND[op](cond[op])) return [`${where}: invalid operand for ${op}${op === "matches" ? " (not a valid regex)" : ""}`];
|
|
54
|
+
if ("days_from" in cond && (!isPath(cond.days_from) || !["gt", "lt"].includes(op))) {
|
|
55
|
+
return [`${where}: days_from names another argument and compares with gt or lt`];
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* KJC-TSK-0948: a rule about how the agent thinks or answers has no tool call to
|
|
62
|
+
* gate. It is declared with the reason, and carries nothing to evaluate.
|
|
63
|
+
*/
|
|
64
|
+
function outOfScopeErrors(rule) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
if (typeof rule.reason !== "string" || !rule.reason.trim()) errors.push(`an ${OUT_OF_SCOPE} rule needs a reason (text)`);
|
|
67
|
+
const gate = GATE_KEYS.filter((k) => k in rule);
|
|
68
|
+
if (gate.length) errors.push(`an ${OUT_OF_SCOPE} rule takes no ${gate.join(", ")}: there is no tool call to gate`);
|
|
69
|
+
return errors;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ruleErrors(rule) {
|
|
73
|
+
if (!isObject(rule)) return ["a rule is a mapping"];
|
|
74
|
+
const errors = [];
|
|
75
|
+
const unknown = [...unknownKeys(rule, RULE_KEYS), ...unknownKeys(rule.when, WHEN_KEYS).map((k) => `when.${k}`)];
|
|
76
|
+
if (unknown.length) errors.push(`unknown key ${unknown.join(", ")}`);
|
|
77
|
+
if (!ID.test(String(rule.id))) errors.push("id must be an inventory id (R- and 10 hex chars)");
|
|
78
|
+
if (!KINDS.includes(rule.kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
|
|
79
|
+
const notText = ["source", "text", "message"].filter((k) => k in rule && typeof rule[k] !== "string");
|
|
80
|
+
if (notText.length) errors.push(`${notText.join(", ")} must be text`);
|
|
81
|
+
if (rule.kind === OUT_OF_SCOPE) return [...errors, ...outOfScopeErrors(rule)];
|
|
82
|
+
// KJC-TSK-0973: any rule may say why it is compiled as it is. A judgment rule,
|
|
83
|
+
// why it takes no condition; a deterministic one, where its condition stops
|
|
84
|
+
// (what breaks the rest is not in the arguments of one call).
|
|
85
|
+
if ("reason" in rule && (typeof rule.reason !== "string" || !rule.reason.trim())) errors.push("reason is text");
|
|
86
|
+
// `examples` are tool calls: the command that runs them checks their shape (kj rules test).
|
|
87
|
+
const tools = [rule.when?.tool].flat();
|
|
88
|
+
if (tools.length === 0 || tools.some((t) => typeof t !== "string" || !t)) errors.push("when.tool names the tool: a glob, or a list where every item is one");
|
|
89
|
+
const lists = [rule.when?.all, rule.when?.any].filter((list) => list !== undefined);
|
|
90
|
+
if (lists.some((list) => !Array.isArray(list))) errors.push("when.all and when.any are lists of conditions");
|
|
91
|
+
else for (const cond of lists.flat()) errors.push(...conditionErrors(cond));
|
|
92
|
+
return errors;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} text the content of .karajan/rules.yml
|
|
97
|
+
* @returns {{rules: object[], errors: string[]}} no rules when there is any error
|
|
98
|
+
*/
|
|
99
|
+
export function parseRules(text) {
|
|
100
|
+
let doc;
|
|
101
|
+
try { doc = yaml.load(text); } catch (err) { return { rules: [], errors: [`invalid YAML: ${err.message.split("\n")[0]}`] }; }
|
|
102
|
+
if (!isObject(doc) || doc.version !== 1) return { rules: [], errors: ["version must be 1"] };
|
|
103
|
+
const unknown = unknownKeys(doc, ["version", "rules"]);
|
|
104
|
+
if (unknown.length) return { rules: [], errors: [`unknown key ${unknown.join(", ")}`] };
|
|
105
|
+
if (!Array.isArray(doc.rules)) return { rules: [], errors: ["rules must be a list"] };
|
|
106
|
+
const errors = doc.rules.flatMap((rule, i) => ruleErrors(rule).map((e) => `rules[${i}] (${rule?.id ?? "no id"}): ${e}`));
|
|
107
|
+
// One rule, one entry: with two, which one a reader sees is an accident of order.
|
|
108
|
+
const ids = doc.rules.map((rule) => rule?.id).filter((id) => typeof id === "string");
|
|
109
|
+
errors.push(...new Set(ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `${id}: repeated, a rule is compiled once`)));
|
|
110
|
+
return { rules: errors.length ? [] : doc.rules, errors };
|
|
111
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule coverage (KJC-TSK-0941, MDR-E, ADR 0016): the inventory of the MD files
|
|
3
|
+
* against the compiled rules, by id. The id hashes the rule's text, so a rule
|
|
4
|
+
* whose text changed in its MD is a new rule with no gate, and what was compiled
|
|
5
|
+
* for the old text is stale: it is in no MD any more.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const NO_GATE = "none";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{id: string}[]} inventory what listRules returned
|
|
12
|
+
* @param {{id: string, kind: string}[]} compiled what parseRules accepted
|
|
13
|
+
* @returns {{rows: object[], stale: object[]}} one row per inventory rule, with
|
|
14
|
+
* its `status` (the kind it was compiled to, or "none"); and the stale compiled rules
|
|
15
|
+
*/
|
|
16
|
+
export function coverage(inventory, compiled) {
|
|
17
|
+
const kinds = new Map(compiled.map((rule) => [rule.id, rule.kind]));
|
|
18
|
+
const rows = new Map();
|
|
19
|
+
for (const rule of inventory) {
|
|
20
|
+
if (!rows.has(rule.id)) rows.set(rule.id, { ...rule, status: kinds.get(rule.id) ?? NO_GATE });
|
|
21
|
+
}
|
|
22
|
+
return { rows: [...rows.values()], stale: compiled.filter((rule) => !rows.has(rule.id)) };
|
|
23
|
+
}
|