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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/audit/ai-slop-findings.js +4 -2
  3. package/src/audit/circular-deps.js +8 -7
  4. package/src/audit/webperf-input.js +3 -1
  5. package/src/cli/advanced-commands.js +1 -1
  6. package/src/cli/register-meta.js +112 -1
  7. package/src/commands/init.js +6 -2
  8. package/src/commands/rules-approve.js +59 -0
  9. package/src/commands/rules-compile.js +116 -0
  10. package/src/commands/rules-decide.js +51 -0
  11. package/src/commands/rules-review.js +44 -0
  12. package/src/commands/rules.js +169 -0
  13. package/src/config/loader.js +23 -1
  14. package/src/environment/adr.js +5 -2
  15. package/src/harden/config-templates.js +3 -0
  16. package/src/harden/human-act.js +70 -0
  17. package/src/harden/phone-sign.js +26 -0
  18. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  19. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  20. package/src/harden/sentinel/sentinel-discard.mjs +4 -2
  21. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  22. package/src/harden/sentinel/sentinel-shell.mjs +17 -0
  23. package/src/harden/sentinel/sessionstart.mjs +18 -9
  24. package/src/harden/sentinel-hooks.js +23 -2
  25. package/src/harden/supervisor-commit.js +13 -56
  26. package/src/mcp/handlers/run-handler.js +5 -0
  27. package/src/mcp/sovereignty-guard.js +16 -15
  28. package/src/orchestrator/preflight-checks.js +4 -3
  29. package/src/policy/supervisor-verify.js +12 -2
  30. package/src/privacy/scan.js +7 -0
  31. package/src/review/gate-gitignore.js +8 -0
  32. package/src/rules/approval-view.js +49 -0
  33. package/src/rules/compiled.js +108 -0
  34. package/src/rules/coverage.js +23 -0
  35. package/src/rules/evaluate.js +60 -0
  36. package/src/rules/inventory.js +118 -0
  37. package/src/sonar/config-resolver.js +19 -1
  38. package/src/utils/run-log.js +74 -2
  39. 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 { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
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
- // Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
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
- if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
110
- throw new Error(
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
- // Un alimentador ciego no conoce el código; automatizar su lectura exige
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
- const ACTIVE_SESSION_THRESHOLD_MS = 60_000;
35
+
35
36
 
36
37
  /**
37
38
  * Check if a pipeline is already running for this project.
38
- * Looks at .kj/run.log modification time.
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
- const logPath = path.join(projectDir, ".kj", "run.log");
48
+ let holder;
46
49
  try {
47
- const stat = fs.statSync(logPath);
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
- // File doesn't exist or can't be read — no active session
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: "No Sonar token or admin credentials configured. Set KJ_SONAR_TOKEN, configure sonarqube.token in kj.config.yml, or save credentials in ~/.karajan/sonar-credentials.json." };
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: "SonarQube is running but no authentication token is configured.",
331
- fix: withDocLink("Fix: run 'kj init' to configure it, or set KJ_SONAR_TOKEN env var, or add sonarqube.token to ~/.karajan/kj.config.yml.", "sonar_token")
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
- ...(unjudged > 0 ? { note: `${unjudged} guardia(s) del sello no existen en este checkout y no se han podido comprobar aquí` } : {}),
125
+ ...(notes.length > 0 ? { note: notes.join("; ") } : {}),
116
126
  };
117
127
  }
@@ -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
+ }