karajan-code 4.39.0 → 4.40.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/init.js +6 -2
- package/src/commands/rules-approve.js +59 -0
- package/src/commands/rules-compile.js +116 -0
- package/src/commands/rules-decide.js +51 -0
- package/src/commands/rules-review.js +44 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- 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 +49 -0
- package/src/rules/compiled.js +108 -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
|
@@ -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,49 @@
|
|
|
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) => (rule.kind === "out-of-scope" ? `reason: ${rule.reason}` : `when: ${JSON.stringify(rule.when)}`);
|
|
14
|
+
|
|
15
|
+
/** How a proposed rule weakens or changes the approved one, or "" when it does not. */
|
|
16
|
+
function weakening(rule, approved) {
|
|
17
|
+
if (approved?.kind !== GATE) return "";
|
|
18
|
+
if (rule.kind !== GATE) return `was ${GATE}, now ${rule.kind}`;
|
|
19
|
+
return JSON.stringify(rule.when) === JSON.stringify(approved.when) ? "" : "condition changed";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object[]} proposed the rules of a checked proposal
|
|
24
|
+
* @param {object[]} current the rules approved today (both files)
|
|
25
|
+
* @param {Set<string>} local ids that go to the unversioned file
|
|
26
|
+
* @param {{versioned: string, unversioned: string}} files the two rules files, as shown
|
|
27
|
+
* @returns {string[]} lines, ready to print
|
|
28
|
+
*/
|
|
29
|
+
export function approvalView(proposed, current, local, files) {
|
|
30
|
+
const approved = new Map(current.map((rule) => [rule.id, rule]));
|
|
31
|
+
const where = (rule) => (local.has(rule.id) ? `${files.unversioned}, not versioned` : files.versioned);
|
|
32
|
+
const shown = (rule) => [`${rule.id} ${rule.kind} → ${where(rule)}`, ` ${rule.text}`, ` ${compiled(rule)}`];
|
|
33
|
+
const weakened = proposed.map((rule) => ({ rule, how: weakening(rule, approved.get(rule.id)) })).filter(({ how }) => how);
|
|
34
|
+
const seen = new Set(weakened.map(({ rule }) => rule.id));
|
|
35
|
+
const rest = proposed.filter((rule) => !seen.has(rule.id));
|
|
36
|
+
const ungated = rest.filter((rule) => rule.kind !== GATE);
|
|
37
|
+
const gates = rest.filter((rule) => rule.kind === GATE);
|
|
38
|
+
const ids = new Set(proposed.map((rule) => rule.id));
|
|
39
|
+
const leaving = current.filter((rule) => !ids.has(rule.id));
|
|
40
|
+
const block = (title, lines) => (lines.length ? [title, ...lines, ""] : []);
|
|
41
|
+
return [
|
|
42
|
+
...block(`⚠ ${weakened.length} rule(s) LOSE or CHANGE force against what is approved today:`, weakened.flatMap(({ rule, how }) => [
|
|
43
|
+
`${rule.id} ${how} → ${where(rule)}`, ` ${rule.text}`, ` was: ${compiled(approved.get(rule.id))}`, ` now: ${compiled(rule)}`,
|
|
44
|
+
])),
|
|
45
|
+
...block(`${ungated.length} rule(s) with NO effective gate: nothing is blocked on them.`, ungated.flatMap(shown)),
|
|
46
|
+
...block(`${gates.length} ${GATE} gate(s):`, gates.flatMap(shown)),
|
|
47
|
+
...block(`${leaving.length} rule(s) LEAVE:`, leaving.map((rule) => `${rule.id} ${rule.text ?? ""}`)),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
if ("reason" in rule) errors.push(`reason belongs to ${OUT_OF_SCOPE} rules only`);
|
|
83
|
+
// `examples` are tool calls: the command that runs them checks their shape (kj rules test).
|
|
84
|
+
const tools = [rule.when?.tool].flat();
|
|
85
|
+
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");
|
|
86
|
+
const lists = [rule.when?.all, rule.when?.any].filter((list) => list !== undefined);
|
|
87
|
+
if (lists.some((list) => !Array.isArray(list))) errors.push("when.all and when.any are lists of conditions");
|
|
88
|
+
else for (const cond of lists.flat()) errors.push(...conditionErrors(cond));
|
|
89
|
+
return errors;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} text the content of .karajan/rules.yml
|
|
94
|
+
* @returns {{rules: object[], errors: string[]}} no rules when there is any error
|
|
95
|
+
*/
|
|
96
|
+
export function parseRules(text) {
|
|
97
|
+
let doc;
|
|
98
|
+
try { doc = yaml.load(text); } catch (err) { return { rules: [], errors: [`invalid YAML: ${err.message.split("\n")[0]}`] }; }
|
|
99
|
+
if (!isObject(doc) || doc.version !== 1) return { rules: [], errors: ["version must be 1"] };
|
|
100
|
+
const unknown = unknownKeys(doc, ["version", "rules"]);
|
|
101
|
+
if (unknown.length) return { rules: [], errors: [`unknown key ${unknown.join(", ")}`] };
|
|
102
|
+
if (!Array.isArray(doc.rules)) return { rules: [], errors: ["rules must be a list"] };
|
|
103
|
+
const errors = doc.rules.flatMap((rule, i) => ruleErrors(rule).map((e) => `rules[${i}] (${rule?.id ?? "no id"}): ${e}`));
|
|
104
|
+
// One rule, one entry: with two, which one a reader sees is an accident of order.
|
|
105
|
+
const ids = doc.rules.map((rule) => rule?.id).filter((id) => typeof id === "string");
|
|
106
|
+
errors.push(...new Set(ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `${id}: repeated, a rule is compiled once`)));
|
|
107
|
+
return { rules: errors.length ? [] : doc.rules, errors };
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic evaluator of compiled rules (KJC-TSK-0944, MDR-B1, ADR 0016):
|
|
3
|
+
* one tool call against the rules parseRules accepted, with no model.
|
|
4
|
+
*
|
|
5
|
+
* A rule says when to deny. A condition over an argument the call does not
|
|
6
|
+
* carry, or cannot be read as the operator needs (a date, a number), does not
|
|
7
|
+
* hold: the rule states a fact about the call, and that fact is not there.
|
|
8
|
+
*/
|
|
9
|
+
import { isObject, toolsOf } from "./compiled.js";
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 86_400_000;
|
|
12
|
+
|
|
13
|
+
/** A tool glob: `*` stands for any run of characters, the rest is literal. */
|
|
14
|
+
const toolMatches = (glob, tool) => {
|
|
15
|
+
const literal = glob.split("*").map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`));
|
|
16
|
+
return new RegExp(`^${literal.join(".*")}$`).test(String(tool));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The value at a dotted path of the tool input, own properties only. */
|
|
20
|
+
const at = (input, dotted) =>
|
|
21
|
+
dotted.split(".").reduce((node, key) => (isObject(node) && Object.hasOwn(node, key) ? node[key] : undefined), input);
|
|
22
|
+
|
|
23
|
+
/** Calendar days (UTC) from one date to another: the same day is 0, whatever the hour. */
|
|
24
|
+
const daysBetween = (from, to) => Math.floor(Date.parse(to) / DAY_MS) - Math.floor(Date.parse(from) / DAY_MS);
|
|
25
|
+
|
|
26
|
+
function holds(cond, input) {
|
|
27
|
+
let value = at(input, cond.arg);
|
|
28
|
+
const present = value !== undefined && value !== null;
|
|
29
|
+
if ("exists" in cond) return present === cond.exists;
|
|
30
|
+
if (!present) return false;
|
|
31
|
+
if ("days_from" in cond) value = daysBetween(at(input, cond.days_from), value);
|
|
32
|
+
if ("equals" in cond) return value === cond.equals;
|
|
33
|
+
if ("in" in cond) return cond.in.includes(value);
|
|
34
|
+
if ("matches" in cond) return typeof value === "string" && new RegExp(cond.matches).test(value);
|
|
35
|
+
if (typeof value !== "number" || Number.isNaN(value)) return false;
|
|
36
|
+
return "gt" in cond ? value > cond.gt : value < cond.lt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const applies = (rule, tool, input) =>
|
|
40
|
+
toolsOf(rule.when).some((glob) => toolMatches(glob, tool))
|
|
41
|
+
&& (rule.when.all ?? []).every((cond) => holds(cond, input))
|
|
42
|
+
&& (!rule.when.any?.length || rule.when.any.some((cond) => holds(cond, input)));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object[]} rules what parseRules returned
|
|
46
|
+
* @param {{tool: string, input?: object}} call
|
|
47
|
+
* @returns {{decision: "allow"} | {decision: "deny", rule_id: string, source: string|null, text: string|null, message: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function evalRules(rules, { tool, input }) {
|
|
50
|
+
const args = isObject(input) ? input : {};
|
|
51
|
+
const hit = rules.find((rule) => rule.kind === "deterministic" && applies(rule, tool, args));
|
|
52
|
+
if (!hit) return { decision: "allow" };
|
|
53
|
+
return {
|
|
54
|
+
decision: "deny",
|
|
55
|
+
rule_id: hit.id,
|
|
56
|
+
source: hit.source ?? null,
|
|
57
|
+
text: hit.text ?? null,
|
|
58
|
+
message: hit.message ?? hit.text ?? `rule ${hit.id}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule inventory (KJC-TSK-0937, MDR-A, ADR 0016). Every rule written in the MD
|
|
3
|
+
* files that govern a session, with a stable id, its literal text and where it
|
|
4
|
+
* lives, so each one can get a gate and a deny can cite it.
|
|
5
|
+
*
|
|
6
|
+
* A rule is a markdown block (a list item with the lines that continue it, or a
|
|
7
|
+
* paragraph), outside code blocks, that carries a normative marker. The id hashes
|
|
8
|
+
* the normalized text, so a rule keeps its id when it moves or is wrapped anew.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const NORMATIVE = /\b(nunca|siempre|prohibido|obligatorio|jam[aá]s|never|always|must|forbidden|required|do not|don't)\b/i;
|
|
16
|
+
|
|
17
|
+
// Emphasis delimiters only: a marker opens after a blank and closes before one (or
|
|
18
|
+
// punctuation), so snake_case names and globs (docs/**/*.md) keep their characters.
|
|
19
|
+
const stripEmphasis = (text) => text
|
|
20
|
+
.replaceAll(/(^|[\s(])\*\*(\S[^*]*)\*\*(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
21
|
+
.replaceAll(/(^|[\s(])__(\S[^_]*)__(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
22
|
+
.replaceAll(/(^|[\s(])\*(\S[^*]*)\*(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
23
|
+
.replaceAll(/(^|[\s(])_(\S[^_]*)_(?=[\s).,;:!?]|$)/g, "$1$2");
|
|
24
|
+
|
|
25
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
26
|
+
|
|
27
|
+
const HELD =""; // private-use char: stands in for a code span while emphasis is stripped
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The text of a markdown line without its list marker, emphasis or code ticks.
|
|
31
|
+
* What sits inside a code span is kept as written, whatever characters it holds.
|
|
32
|
+
*/
|
|
33
|
+
const plain = (line) => {
|
|
34
|
+
const spans = [];
|
|
35
|
+
const held = line
|
|
36
|
+
.replace(LIST_ITEM, "")
|
|
37
|
+
.replaceAll(/`([^`]*)`/g, (_m, code) => `${HELD}${spans.push(code) - 1}${HELD}`);
|
|
38
|
+
return stripEmphasis(held)
|
|
39
|
+
.replaceAll(new RegExp(`${HELD}(\\d+)${HELD}`, "g"), (_m, n) => spans[Number(n)])
|
|
40
|
+
.replaceAll(/\s+/g, " ")
|
|
41
|
+
.trim();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ruleId = (text) => "R-" + createHash("sha256").update(text.toLowerCase()).digest("hex").slice(0, 10);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} markdown
|
|
48
|
+
* @param {string} file
|
|
49
|
+
* @returns {{id: string, text: string, file: string, line: number}[]}
|
|
50
|
+
*/
|
|
51
|
+
export function extractRules(markdown, file) {
|
|
52
|
+
const rules = [];
|
|
53
|
+
let fence = null; // the open code fence (``` or ~~~, any length), closed by one as long
|
|
54
|
+
let comment = false; // inside an HTML comment that has not closed yet
|
|
55
|
+
let block = null; // the list item or paragraph being read: { line, parts }
|
|
56
|
+
const flush = () => {
|
|
57
|
+
const text = block ? plain(block.parts.join(" ")) : "";
|
|
58
|
+
if (text && NORMATIVE.test(text)) rules.push({ id: ruleId(text), text, file, line: block.line });
|
|
59
|
+
block = null;
|
|
60
|
+
};
|
|
61
|
+
const lines = markdown.split("\n");
|
|
62
|
+
// A memory file's YAML frontmatter is metadata, not rules.
|
|
63
|
+
const frontmatterEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
64
|
+
lines.forEach((raw, i) => {
|
|
65
|
+
if (i <= frontmatterEnd) return;
|
|
66
|
+
const mark = /^\s*(`{3,}|~{3,})/.exec(raw)?.[1];
|
|
67
|
+
if (mark && !fence) { flush(); fence = mark; return; }
|
|
68
|
+
if (mark && mark[0] === fence[0] && mark.length >= fence.length && raw.trim() === mark) { fence = null; return; }
|
|
69
|
+
if (fence) return;
|
|
70
|
+
if (comment || /^\s*<!--/.test(raw)) { flush(); comment = !raw.includes("-->"); return; }
|
|
71
|
+
if (!raw.trim() || /^\s*#/.test(raw) || /^\s*\|/.test(raw)) { flush(); return; }
|
|
72
|
+
// KJC-BUG-0271: the unit is the markdown block. A list marker opens a new
|
|
73
|
+
// one; any other line continues the block it follows (a wrapped rule).
|
|
74
|
+
if (LIST_ITEM.test(raw)) flush();
|
|
75
|
+
block ??= { line: i + 1, parts: [] };
|
|
76
|
+
block.parts.push(raw.trim());
|
|
77
|
+
});
|
|
78
|
+
flush();
|
|
79
|
+
return rules;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The MD files that govern a session in projectDir (KJC-TSK-0942): its CLAUDE.md
|
|
84
|
+
* and AGENTS.md, the global ~/.claude/CLAUDE.md and the project's feedback memories.
|
|
85
|
+
* @param {string} projectDir
|
|
86
|
+
* @param {{home?: string}} [opts]
|
|
87
|
+
*/
|
|
88
|
+
export function ruleSources(projectDir, { home = os.homedir() } = {}) {
|
|
89
|
+
// Claude Code names a project's folder after its path, every non-alphanumeric char as "-".
|
|
90
|
+
const memory = path.join(home, ".claude", "projects", path.resolve(projectDir).replaceAll(/[^A-Za-z0-9]/g, "-"), "memory");
|
|
91
|
+
let feedback = [];
|
|
92
|
+
try {
|
|
93
|
+
feedback = fs.readdirSync(memory).filter((f) => /^feedback_.*\.md$/.test(f)).sort().map((f) => path.join(memory, f));
|
|
94
|
+
} catch { /* no memories for this project */ }
|
|
95
|
+
return [
|
|
96
|
+
path.join(projectDir, "CLAUDE.md"),
|
|
97
|
+
path.join(projectDir, "AGENTS.md"),
|
|
98
|
+
path.join(home, ".claude", "CLAUDE.md"),
|
|
99
|
+
...feedback,
|
|
100
|
+
].filter((f) => fs.existsSync(f));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether an MD file is part of the project, and so as public as the repo is. */
|
|
104
|
+
export const inProject = (file, projectDir) => {
|
|
105
|
+
const rel = path.relative(projectDir, file);
|
|
106
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** A source as a rules file can carry it: from the project's root, or from `~`. */
|
|
110
|
+
export const shownSource = (file, projectDir, home = os.homedir()) => {
|
|
111
|
+
if (inProject(file, projectDir)) return path.relative(projectDir, file);
|
|
112
|
+
return inProject(file, home) ? path.join("~", path.relative(home, file)) : file;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Every rule of every governing MD file, in source order. */
|
|
116
|
+
export function listRules(projectDir, opts = {}) {
|
|
117
|
+
return ruleSources(projectDir, opts).flatMap((file) => extractRules(fs.readFileSync(file, "utf8"), file));
|
|
118
|
+
}
|