karajan-code 4.17.0 → 4.19.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/README.md +10 -0
- package/package.json +4 -2
- package/scripts/dual-publish.mjs +67 -0
- package/scripts/verify-pack.mjs +8 -5
- package/src/agents/claude-agent.js +7 -2
- package/src/checks/guarantee-level.js +38 -0
- package/src/cli/register-meta.js +33 -2
- package/src/cli/register-roles-skills.js +17 -1
- package/src/commands/agents.js +4 -2
- package/src/commands/doctor.js +16 -0
- package/src/commands/policy.js +103 -9
- package/src/commands/review-gate.js +111 -4
- package/src/environment/briefs.js +3 -0
- package/src/environment/playbook.js +3 -1
- package/src/harden/sentinel-hooks.js +92 -2
- package/src/harden/workflow-engine.js +35 -3
- package/src/harden/workflow-templates.js +28 -0
- package/src/mutate/diff-scope.js +6 -2
- package/src/policy/add.js +111 -0
- package/src/policy/decisions.js +42 -0
- package/src/policy/engine.js +44 -238
- package/src/policy/exceptions.js +60 -0
- package/src/review/mutation-pregate.js +48 -0
- package/src/review/policy-gate.js +26 -0
- package/src/start/sweep.js +46 -4
- package/src/utils/update-check.js +25 -6
- package/src/policy/glob.js +0 -44
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
|
-
import { checkVerdict } from "../review/verdict-store.js";
|
|
11
|
+
import { checkVerdict, diffHash } from "../review/verdict-store.js";
|
|
12
12
|
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
|
|
16
|
+
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
16
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
17
18
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
18
19
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
20
|
+
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
21
|
+
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
22
|
+
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
23
|
+
import { evaluatePolicyGate } from "../review/policy-gate.js";
|
|
19
24
|
|
|
20
25
|
// KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
|
|
21
26
|
// (before spending sonar/reviewer effort) AND on --check — the pre-commit
|
|
@@ -75,6 +80,28 @@ function printVerdict(record) {
|
|
|
75
80
|
export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
76
81
|
const projectDir = config?.projectDir || process.cwd();
|
|
77
82
|
const diff = await rawDiff(flags.range);
|
|
83
|
+
// KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
|
|
84
|
+
// arbitrable — solomon se niega antes de gastar un token. Y una policy
|
|
85
|
+
// inválida cierra el arbitraje en vez de abrirlo (fail closed, catch de
|
|
86
|
+
// codex: un `sec` vacío por error de carga NO puede significar "adelante").
|
|
87
|
+
const files = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
88
|
+
const pol = loadPolicy({ projectDir });
|
|
89
|
+
if (pol.errors.length > 0) {
|
|
90
|
+
for (const e of pol.errors) console.log(`✗ ${e}`);
|
|
91
|
+
console.log("⚖ Solomon no arbitra con policy.yml inválida — corrígela primero: una regla que miente es peor que ninguna.");
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return { ruling: "reject", reasoning: "invalid policy.yml — arbitration refused (fail closed)" };
|
|
94
|
+
}
|
|
95
|
+
const sec = checkStagedDiff(pol.policy, { files, netLinesAdded: 0 }).filter((v) => v.class === "security" && v.enforcement === "deny");
|
|
96
|
+
if (sec.length > 0) {
|
|
97
|
+
for (const v of sec) {
|
|
98
|
+
const where = v.file ? ` (${v.file})` : "";
|
|
99
|
+
console.log(`✗ policy [${v.rule_id}] ${v.reason}${where}`);
|
|
100
|
+
}
|
|
101
|
+
console.log("⚖ Solomon no arbitra hallazgos de clase seguridad — resuélvelos; la regla no es negociable.");
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
|
|
104
|
+
}
|
|
78
105
|
const res = await runSolomonArbitration({ diff, position: flags.position, config, logger, projectDir });
|
|
79
106
|
if (res.ruling === "approve") {
|
|
80
107
|
console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
|
|
@@ -122,7 +149,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
122
149
|
// Field case: a 522-line PR sailed past the warning with a
|
|
123
150
|
// rationalization — where the project wants teeth, pr_size: "block"
|
|
124
151
|
// rejects deterministically with a NAMED escape. Default stays warn.
|
|
125
|
-
|
|
152
|
+
// PL-C (KJC-TSK-0735), fuente única de umbrales: si la policy declara el
|
|
153
|
+
// invariante net_lines_added, SU max es el umbral — el mismo número que
|
|
154
|
+
// aplican kj policy check y el tier C de CI; method_gates queda de fallback.
|
|
155
|
+
const pol = loadPolicy({ projectDir });
|
|
156
|
+
const locInv = pol.errors.length === 0
|
|
157
|
+
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
158
|
+
: null;
|
|
159
|
+
const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_warn ?? 150;
|
|
160
|
+
const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
|
|
126
161
|
if (sizeWarn > 0) {
|
|
127
162
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
128
163
|
const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
|
|
@@ -131,12 +166,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
131
166
|
if (process.env.KJ_ALLOW_LARGE_PR === "1") {
|
|
132
167
|
console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
|
|
133
168
|
} else if (sizePolicy === "block") {
|
|
134
|
-
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
169
|
+
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
135
170
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
136
171
|
process.exitCode = 1;
|
|
137
172
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
138
173
|
} else {
|
|
139
|
-
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
174
|
+
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
140
175
|
}
|
|
141
176
|
}
|
|
142
177
|
}
|
|
@@ -187,6 +222,54 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
187
222
|
return { verdict: "rejected", reviewer: "tests-with-code", issues: [{ severity: "high", description: tests.reason }] };
|
|
188
223
|
}
|
|
189
224
|
|
|
225
|
+
// KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
|
|
226
|
+
// --staged Y en --check — el pre-commit hereda los dientes sin regenerar
|
|
227
|
+
// hooks. enforcement=warn avisa; deny cierra salvo excepción probatoria
|
|
228
|
+
// (KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, registrada con identidad y hash
|
|
229
|
+
// del diff); class=security cierra sin escape y sin arbitraje.
|
|
230
|
+
let net = 0;
|
|
231
|
+
for (const l of (await rawDiff(flags.range, ["--numstat"])).split("\n")) {
|
|
232
|
+
const [a, r] = l.trim().split(/\s+/);
|
|
233
|
+
if (a && a !== "-") net += Number(a) || 0;
|
|
234
|
+
if (r && r !== "-") net -= Number(r) || 0;
|
|
235
|
+
}
|
|
236
|
+
// GOV-B (KJC-TSK-0746): las permanentes concedidas (kj policy grant)
|
|
237
|
+
// eximen su regla mientras viven; el descarte de líneas corruptas se dice.
|
|
238
|
+
const std = loadStandingExceptions(projectDir);
|
|
239
|
+
if (std.discarded > 0) console.log(`⚠ policy: ${std.discarded} línea(s) corruptas descartadas en policy-exceptions.jsonl — revísalo`);
|
|
240
|
+
const gate = evaluatePolicyGate({
|
|
241
|
+
policy: pol.policy, errors: pol.errors, files: changedFiles, netLinesAdded: net,
|
|
242
|
+
diffHashValue: diffHash(diff),
|
|
243
|
+
recordException: (entry) => recordPolicyException({ projectDir, entry }),
|
|
244
|
+
standingExceptions: std.standing,
|
|
245
|
+
});
|
|
246
|
+
const at = (v) => (v.file ? ` (${v.file})` : "");
|
|
247
|
+
for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
|
|
248
|
+
for (const e of gate.exempted) {
|
|
249
|
+
console.log(e.standing
|
|
250
|
+
? `⚠ policy standing [${e.rule_id}] — excepción permanente viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
|
|
251
|
+
: `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
|
|
252
|
+
}
|
|
253
|
+
// GOV-C (KJC-TSK-0747): las decisiones de chokepoint dejan rastro
|
|
254
|
+
// hash-encadenado — cada deny, cada excepción, y el allow del commit.
|
|
255
|
+
const chokepoint = flags.check ? "commit" : "review";
|
|
256
|
+
const seal = (decision, extra = {}) =>
|
|
257
|
+
recordGateDecision(projectDir, { decision, chokepoint, policy_hash: policyFileHash(projectDir), artifact_hash: diffHash(diff), ...extra });
|
|
258
|
+
if (gate.exempted.length > 0) seal("exempt", { rule_ids: gate.exempted.map((e) => e.rule_id) });
|
|
259
|
+
if (!gate.ok) {
|
|
260
|
+
for (const d of gate.denials) {
|
|
261
|
+
const sec = d.class === "security" ? " [security — sin escape ni arbitraje]" : "";
|
|
262
|
+
console.log(`✗ policy [${d.rule_id}]${sec} ${d.reason}${at(d)}`);
|
|
263
|
+
}
|
|
264
|
+
seal("deny", { rule_ids: gate.denials.map((d) => d.rule_id) });
|
|
265
|
+
const reason = gate.invalid
|
|
266
|
+
? "policy.yml inválida — una regla que miente es peor que ninguna: corrígela"
|
|
267
|
+
: `${gate.denials.length} violación(es) de policy con enforcement=deny — el diff no entra`;
|
|
268
|
+
console.log(`✗ policy gate: ${reason}`);
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
return { verdict: "rejected", reviewer: "policy", issues: gate.denials.map((d) => ({ severity: "high", file: d.file, description: `[${d.rule_id}] ${d.reason}` })) };
|
|
271
|
+
}
|
|
272
|
+
|
|
190
273
|
if (flags.check) {
|
|
191
274
|
// KJC-BUG-0132 (issue #1344): a PURE merge commit stages no content of
|
|
192
275
|
// its own — everything it brings reached the parent branches already
|
|
@@ -206,6 +289,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
206
289
|
console.log(res.ok
|
|
207
290
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
208
291
|
: `✗ ${res.reason}`);
|
|
292
|
+
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
|
|
293
|
+
if (res.ok) seal("allow");
|
|
209
294
|
process.exitCode = res.ok ? 0 : 1;
|
|
210
295
|
return res;
|
|
211
296
|
}
|
|
@@ -241,6 +326,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
241
326
|
}
|
|
242
327
|
}
|
|
243
328
|
|
|
329
|
+
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
330
|
+
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
331
|
+
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
332
|
+
// cierra ANTES de gastar reviewer; warn viaja como advisory en su task.
|
|
333
|
+
const mut = flags.staged && !flags.range ? await runMutationPregate({ config, projectDir }) : { enabled: false, ok: true };
|
|
334
|
+
if (mut.enabled) {
|
|
335
|
+
if (mut.available === false) {
|
|
336
|
+
console.log(`⚠ mutation pre-gate no disponible: ${mut.reason} — degradado avisando (method_gates.mutation)`);
|
|
337
|
+
} else if (mut.survived.length > 0) {
|
|
338
|
+
console.log(`Mutantes supervivientes en el diff — score ${mut.score ?? "n/a"}%:`);
|
|
339
|
+
for (const m of mut.survived) console.log(` ✗ ${formatSurvivor(m)}`);
|
|
340
|
+
if (!mut.ok) {
|
|
341
|
+
console.log("✗ mutation gate: la suite verde no PRUEBA el cambio — mata los supervivientes antes del review (method_gates.mutation: block).");
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return { verdict: "rejected", reviewer: "mutation", issues: mut.survived.map((m) => ({ severity: "high", file: m.file, description: formatSurvivor(m) })) };
|
|
344
|
+
}
|
|
345
|
+
task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
|
|
346
|
+
+ `Surviving mutants on the changed lines (weak asserts — fold into your review):\n`
|
|
347
|
+
+ mut.survived.map((m) => `- ${formatSurvivor(m)}`).join("\n");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
244
351
|
const record = await runOneShotReview({ diff, task, config, logger, projectDir });
|
|
245
352
|
printVerdict(record);
|
|
246
353
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
@@ -71,6 +71,9 @@ const B = {
|
|
|
71
71
|
[
|
|
72
72
|
...(tdd ? ["TDD is ON: the failing test exists FIRST, then the code."] : []),
|
|
73
73
|
"The suite is never left red, and never goes green by deleting or skipping tests.",
|
|
74
|
+
...(config?.method_gates?.mutation
|
|
75
|
+
? ["Green is not proof: run `kj mutate --since <base>` after the suite passes and BEFORE review — kill surviving mutants (weak asserts) first."]
|
|
76
|
+
: []),
|
|
74
77
|
],
|
|
75
78
|
"green suite + the list of new/changed tests and what each one pins.");
|
|
76
79
|
},
|
|
@@ -61,7 +61,9 @@ Invariants (the git gates enforce these — they are not suggestions):
|
|
|
61
61
|
\`kj_rag_query\` tool: reach for it before grepping by hand.
|
|
62
62
|
- ${trackingLine(stateBackend, boardName)}
|
|
63
63
|
- Tests prove behavior: the failing test exists first (TDD), and the suite
|
|
64
|
-
is never left red.
|
|
64
|
+
is never left red. Green is not proof: with \`method_gates.mutation\`
|
|
65
|
+
declared, run \`kj mutate --since <base>\` after green and BEFORE the
|
|
66
|
+
review — a surviving mutant is a weak assert; kill it first.
|
|
65
67
|
- Every diff is reviewed by a DIFFERENT AI before it is committed
|
|
66
68
|
(\`kj review --staged\`): verdicts bind to the exact diff — change the code
|
|
67
69
|
and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
|
|
@@ -88,7 +88,7 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
88
88
|
// a tool call (PostToolUse, always exit 0).
|
|
89
89
|
import { relative } from "node:path";
|
|
90
90
|
import { CODE, TESTS, ROOT, load, save, session } from "./sentinel-lib.mjs";
|
|
91
|
-
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII"];
|
|
91
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY"];
|
|
92
92
|
let raw = "";
|
|
93
93
|
process.stdin.on("data", (d) => { raw += d; });
|
|
94
94
|
process.stdin.on("end", () => {
|
|
@@ -299,7 +299,62 @@ process.stdin.on("end", () => {
|
|
|
299
299
|
// en cualquier posicion, y una ruta entrecomillada con espacios es
|
|
300
300
|
// invisible al tokenizador: ambas => deny conservador (endpoint
|
|
301
301
|
// pedido por el reviewer; friccion asumida, el remedio es literal).
|
|
302
|
-
|
|
302
|
+
// KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
|
|
303
|
+
// la comilla de CIERRE de un string + un redirect literal + la de
|
|
304
|
+
// apertura del siguiente creaban la ilusion de ruta con espacios y
|
|
305
|
+
// denegaban el flujo estandar commit -m "..." > log && echo "ok".
|
|
306
|
+
// Se recorre alternando estado (doctrina del parseCommand del motor):
|
|
307
|
+
// solo el CONTENIDO realmente entrecomillado con slash y espacio
|
|
308
|
+
// deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
|
|
309
|
+
// y la comilla sin cerrar (no verificable).
|
|
310
|
+
// ...y por TOKEN completo, no por span: spans adyacentes concatenan
|
|
311
|
+
// ("dir with"" spaces/file" es UNA ruta) — catch de codex.
|
|
312
|
+
const quotedPathWithSpaces = (s) => {
|
|
313
|
+
// Un backslash pegado a comilla o blanco (comilla escapada,
|
|
314
|
+
// espacio escapado) altera donde CIERRA el string o esconde
|
|
315
|
+
// espacios: opaco => deny (doctrina del motor) — catch de codex.
|
|
316
|
+
// Caracteres construidos con fromCharCode: CERO escaping en este
|
|
317
|
+
// template — fuente y artefacto identicos, nada que malinterpretar
|
|
318
|
+
// (el doble-escaping confundio cuatro reviews; solomon desestimo
|
|
319
|
+
// la primera y las demas se volvieron ilegibles igual).
|
|
320
|
+
// El punto escapado de un sed y similares siguen libres.
|
|
321
|
+
const BS = String.fromCharCode(92);
|
|
322
|
+
const TAB = String.fromCharCode(9);
|
|
323
|
+
const NL = String.fromCharCode(10);
|
|
324
|
+
for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
|
|
325
|
+
const n = s[i + 1] || "";
|
|
326
|
+
if (n === '"' || n === "'" || n === " " || n === TAB) return true;
|
|
327
|
+
}
|
|
328
|
+
let q = null;
|
|
329
|
+
let tok = "";
|
|
330
|
+
let hadQuote = false;
|
|
331
|
+
const flush = () => {
|
|
332
|
+
const bad = hadQuote && tok.includes("/") && tok.includes(" ");
|
|
333
|
+
tok = "";
|
|
334
|
+
hadQuote = false;
|
|
335
|
+
return bad;
|
|
336
|
+
};
|
|
337
|
+
for (const ch of s) {
|
|
338
|
+
if (q) {
|
|
339
|
+
if (ch === q) q = null;
|
|
340
|
+
else tok += ch;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (ch === '"' || ch === "'") {
|
|
344
|
+
q = ch;
|
|
345
|
+
hadQuote = true;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
|
|
349
|
+
if (flush()) return true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
tok += ch;
|
|
353
|
+
}
|
|
354
|
+
if (q !== null) return true; // comilla sin cerrar: no verificable
|
|
355
|
+
return flush();
|
|
356
|
+
};
|
|
357
|
+
if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
|
|
303
358
|
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
304
359
|
else {
|
|
305
360
|
console.error("kj sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado).");
|
|
@@ -329,6 +384,41 @@ process.stdin.on("end", () => {
|
|
|
329
384
|
}
|
|
330
385
|
}
|
|
331
386
|
}
|
|
387
|
+
// KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
|
|
388
|
+
// la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
|
|
389
|
+
// PL-A); los defaults del supervisor viven en el motor y el check
|
|
390
|
+
// PROTECTED inline de arriba queda como red fail-closed si kj falta.
|
|
391
|
+
// Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
|
|
392
|
+
// gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
|
|
393
|
+
// deny duro (el remedio es restaurar kj); cualquier otro fallo de
|
|
394
|
+
// evaluacion = deny con escape humano registrable — la sesion no queda
|
|
395
|
+
// presa de un typo de YAML, pero abrirla es decision del usuario.
|
|
396
|
+
if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
|
|
397
|
+
// PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
|
|
398
|
+
// kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
|
|
399
|
+
const actorRole = process.env.KJ_POLICY_ROLE || "coder";
|
|
400
|
+
const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
|
|
401
|
+
if (pres.error || pres.status === null) {
|
|
402
|
+
console.error("kj sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente.");
|
|
403
|
+
process.exit(2);
|
|
404
|
+
}
|
|
405
|
+
if (pres.status === 2) {
|
|
406
|
+
let v = null;
|
|
407
|
+
try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
|
|
408
|
+
const secure = !!(v && v.class === "security");
|
|
409
|
+
if (!secure && process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
410
|
+
else {
|
|
411
|
+
console.error("kj sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)"));
|
|
412
|
+
process.exit(2);
|
|
413
|
+
}
|
|
414
|
+
} else if (pres.status !== 0) {
|
|
415
|
+
if (process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
416
|
+
else {
|
|
417
|
+
console.error("kj sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada).");
|
|
418
|
+
process.exit(2);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
332
422
|
if (EDIT_TOOLS.includes(tool)) {
|
|
333
423
|
const file = input.file_path || input.notebook_path;
|
|
334
424
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
@@ -9,8 +9,29 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
|
|
12
15
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
13
|
-
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
16
|
+
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
|
+
|
|
18
|
+
// La versión del kj que corre harden — es la que se pinea en el fallback
|
|
19
|
+
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
20
|
+
// bundle SEA no hay package.json junto al módulo y una lectura eager a
|
|
21
|
+
// nivel de módulo tumbaba el binario entero al cargar (ENOENT /package.json).
|
|
22
|
+
let _ownPkg;
|
|
23
|
+
function ownPkg() {
|
|
24
|
+
if (_ownPkg === undefined) {
|
|
25
|
+
try {
|
|
26
|
+
_ownPkg = JSON.parse(
|
|
27
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"),
|
|
28
|
+
);
|
|
29
|
+
} catch {
|
|
30
|
+
_ownPkg = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return _ownPkg;
|
|
34
|
+
}
|
|
14
35
|
|
|
15
36
|
const BLOCK_VERSION = 1;
|
|
16
37
|
const WORKFLOWS_DIR = join(".github", "workflows");
|
|
@@ -50,13 +71,21 @@ export function installWorkflows({
|
|
|
50
71
|
profile = "standard",
|
|
51
72
|
mutation = false,
|
|
52
73
|
dryRun = false,
|
|
74
|
+
kjVersion = null,
|
|
53
75
|
} = {}) {
|
|
54
76
|
const dir = join(projectDir, WORKFLOWS_DIR);
|
|
55
77
|
const pm = detectPackageManager(projectDir);
|
|
56
78
|
const quality = qualityWorkflowFor(language, pm, hasLintScript(projectDir));
|
|
57
79
|
const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
|
|
58
80
|
const mut = mutation ? mutationWorkflowFor(language, pm) : null;
|
|
59
|
-
|
|
81
|
+
// PL-C: el tier C solo existe donde el proyecto DECLARA policy. Versión a
|
|
82
|
+
// pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
|
|
83
|
+
// recurso inalcanzable en los caminos reales.
|
|
84
|
+
const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
|
|
85
|
+
const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
|
|
86
|
+
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
|
|
87
|
+
: null;
|
|
88
|
+
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
|
|
60
89
|
const results = [];
|
|
61
90
|
for (const wf of all) {
|
|
62
91
|
const target = join(dir, wf.file);
|
|
@@ -76,7 +105,10 @@ export function installWorkflows({
|
|
|
76
105
|
});
|
|
77
106
|
if (!dryRun && action !== "unchanged") {
|
|
78
107
|
mkdirSync(dir, { recursive: true });
|
|
79
|
-
|
|
108
|
+
// Newline final SIEMPRE: prettier (y POSIX) lo exigen y el bloque
|
|
109
|
+
// gestionado no lo garantiza — sin esto, el format-check del propio
|
|
110
|
+
// pre-commit rechaza el fichero recién generado.
|
|
111
|
+
writeFileSync(target, content.endsWith("\n") ? content : `${content}\n`);
|
|
80
112
|
}
|
|
81
113
|
results.push({ file: label, action });
|
|
82
114
|
}
|
|
@@ -54,6 +54,34 @@ export const WORKFLOWS = [
|
|
|
54
54
|
{ file: "kj-no-ai-attribution.yml", blockId: "wf-no-ai", body: NO_AI_ATTRIBUTION_WORKFLOW },
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
// PL-C (KJC-TSK-0735) — tier C del ADR 0001: re-check merge-blocking en CI
|
|
58
|
+
// (cubre el hook local manipulado). Solo se siembra con policy declarada;
|
|
59
|
+
// npx PINEADO a la versión del kj que corrió harden — jamás @latest en CI.
|
|
60
|
+
export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
|
|
61
|
+
"name: Policy",
|
|
62
|
+
"on:",
|
|
63
|
+
" pull_request:",
|
|
64
|
+
"permissions:",
|
|
65
|
+
" contents: read",
|
|
66
|
+
"jobs:",
|
|
67
|
+
" policy-check:",
|
|
68
|
+
" runs-on: ubuntu-latest",
|
|
69
|
+
" steps:",
|
|
70
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
71
|
+
" with:",
|
|
72
|
+
" fetch-depth: 0",
|
|
73
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
74
|
+
" with:",
|
|
75
|
+
" node-version: 22",
|
|
76
|
+
" - name: kj policy check (strict) on the PR diff",
|
|
77
|
+
" env:",
|
|
78
|
+
" BASE_REF: ${{ github.base_ref }}",
|
|
79
|
+
" run: |",
|
|
80
|
+
` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes ${kjName}@${kjVersion}"; fi`,
|
|
81
|
+
' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
|
|
82
|
+
"", // línea en blanco pre-marcador: prettier la exige tras un block scalar
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
57
85
|
const header = (steps) =>
|
|
58
86
|
[
|
|
59
87
|
"name: Quality",
|
package/src/mutate/diff-scope.js
CHANGED
|
@@ -89,8 +89,12 @@ export function buildScope({ language, files, ranges }) {
|
|
|
89
89
|
* the injectable git runner (defaults to `runCommand`).
|
|
90
90
|
* @param {{since: string, language: string, toRef?: string, run?: Function}} params
|
|
91
91
|
*/
|
|
92
|
-
export async function getDiffScope({ since, language, toRef = "HEAD", run = runCommand }) {
|
|
93
|
-
|
|
92
|
+
export async function getDiffScope({ since, language, toRef = "HEAD", staged = false, run = runCommand }) {
|
|
93
|
+
// MUT-A (KJC-TSK-0716): staged=true scopea al ÍNDICE (git diff --cached) —
|
|
94
|
+
// lo que el review gate evalúa. since..HEAD con since=HEAD sería un diff
|
|
95
|
+
// VACÍO y el pre-gate no mutaría nada (catch de codex, agravado).
|
|
96
|
+
const args = staged ? ["diff", "--unified=0", "--cached"] : ["diff", "--unified=0", `${since}..${toRef}`];
|
|
97
|
+
const result = await run("git", args).catch(
|
|
94
98
|
() => null,
|
|
95
99
|
);
|
|
96
100
|
if (!result || result.exitCode !== 0) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj policy add "<regla hablada>"` (PL-C, KJC-TSK-0735) — el usuario habla,
|
|
3
|
+
* kj escribe: un agente traduce a UNA regla candidata del vocabulario
|
|
4
|
+
* CERRADO, parsePolicy valida el merge (lo intraducible se RECHAZA, jamás
|
|
5
|
+
* se inventa — el YAML es artefacto, no interfaz), se propone el diff y NO
|
|
6
|
+
* se aplica sin confirmación explícita (--yes).
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import yaml from "js-yaml";
|
|
11
|
+
import { parsePolicy } from "@karajan-family/governance";
|
|
12
|
+
import { createAgent } from "../agents/index.js";
|
|
13
|
+
import { resolveRole } from "../config/role-resolver.js";
|
|
14
|
+
|
|
15
|
+
const PROMPT = (text) => `Traduce esta regla de proyecto hablada a un FRAGMENTO YAML del vocabulario
|
|
16
|
+
cerrado v1 de policy de Karajan. Vocabulario COMPLETO (nada fuera de esto):
|
|
17
|
+
roles.<rol>.write|shell con allow/deny (listas de globs o patrones de
|
|
18
|
+
comando), enforcement (warn|deny), class (security); invariants con
|
|
19
|
+
{id, kind: diff-threshold, metric: net_lines_added, max, enforcement}.
|
|
20
|
+
Responde SOLO el fragmento YAML (claves raíz: roles y/o invariants), sin
|
|
21
|
+
explicación ni fences. Si la regla NO cabe en el vocabulario, responde
|
|
22
|
+
exactamente: NO_TRADUCIBLE.
|
|
23
|
+
|
|
24
|
+
Regla: ${text}`;
|
|
25
|
+
|
|
26
|
+
async function defaultTranslate({ text, config, logger }) {
|
|
27
|
+
const role = resolveRole(config, "reviewer");
|
|
28
|
+
const res = await createAgent(role.provider, config, logger).reviewTask({ prompt: PROMPT(text), role: "reviewer" });
|
|
29
|
+
if (!res?.ok) throw new Error(`el agente traductor (${role.provider}) falló: ${res?.error || "sin salida"}`);
|
|
30
|
+
return String(res.output || "").replace(/^```[a-z]*\n?|```\s*$/g, "").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge conservador: las listas se CONCATENAN (añadir jamás borra), los
|
|
34
|
+
// escalares nuevos solo entran donde no había valor.
|
|
35
|
+
function mergeFragment(doc, frag) {
|
|
36
|
+
const out = { version: 1, ...doc };
|
|
37
|
+
for (const [role, caps] of Object.entries(frag.roles || {})) {
|
|
38
|
+
out.roles ||= {};
|
|
39
|
+
out.roles[role] ||= {};
|
|
40
|
+
for (const [cap, spec] of Object.entries(caps || {})) {
|
|
41
|
+
const cur = (out.roles[role][cap] ||= {});
|
|
42
|
+
for (const [k, v] of Object.entries(spec || {})) {
|
|
43
|
+
cur[k] = Array.isArray(v) ? [...new Set([...(cur[k] || []), ...v])] : (cur[k] ?? v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (frag.invariants) out.invariants = [...(doc.invariants || []), ...frag.invariants];
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const naiveDiff = (oldText, newText) => {
|
|
52
|
+
const oldLines = new Set(oldText.split("\n"));
|
|
53
|
+
const newLines = new Set(newText.split("\n"));
|
|
54
|
+
return [
|
|
55
|
+
...[...oldLines].filter((l) => l.trim() && !newLines.has(l)).map((l) => `- ${l}`),
|
|
56
|
+
...[...newLines].filter((l) => l.trim() && !oldLines.has(l)).map((l) => `+ ${l}`),
|
|
57
|
+
].join("\n");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export async function policyAddCommand({ text, config = {}, flags = {}, logger = console, deps = {} }) {
|
|
61
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
62
|
+
if (!text?.trim()) {
|
|
63
|
+
logger.error?.('policy add: falta la regla — kj policy add "el coder no toca los workflows"');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const { translateFn = defaultTranslate } = deps;
|
|
67
|
+
const raw = await translateFn({ text, config, logger });
|
|
68
|
+
if (raw.includes("NO_TRADUCIBLE")) {
|
|
69
|
+
logger.error?.("policy add: la regla no cabe en el vocabulario cerrado v1 — no se inventa nada; exprésala como write/shell/diff-threshold o queda como instrucción de prompt");
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
let frag;
|
|
73
|
+
try {
|
|
74
|
+
frag = yaml.load(raw);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logger.error?.(`policy add: el agente no devolvió YAML válido (${err.message}) — nada que aplicar`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (typeof frag !== "object" || frag === null || Object.keys(frag).some((k) => !["roles", "invariants"].includes(k))) {
|
|
80
|
+
logger.error?.("policy add: el fragmento solo puede declarar roles y/o invariants — rechazado (vocabulario cerrado)");
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const file = join(projectDir, ".karajan", "policy.yml");
|
|
85
|
+
let current = "";
|
|
86
|
+
try {
|
|
87
|
+
current = readFileSync(file, "utf8");
|
|
88
|
+
} catch { /* sin policy previa: se propone crearla */ }
|
|
89
|
+
const merged = mergeFragment(current ? yaml.load(current) || {} : {}, frag);
|
|
90
|
+
const proposed = yaml.dump(merged, { lineWidth: 100 });
|
|
91
|
+
const { errors } = parsePolicy(proposed);
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
for (const e of errors) logger.error?.(`✗ ${e}`);
|
|
94
|
+
logger.error?.("policy add: la regla traducida no valida contra el vocabulario del motor — rechazada, no se inventa");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger.info?.(`policy add — diff propuesto para .karajan/policy.yml:\n${naiveDiff(current, proposed)}`);
|
|
99
|
+
if (current.includes("#")) {
|
|
100
|
+
logger.warn?.("⚠ policy add: el fichero actual tiene comentarios y el rewrite NO los preserva — revisa el diff antes de confirmar");
|
|
101
|
+
}
|
|
102
|
+
if (!flags.yes) {
|
|
103
|
+
logger.info?.("policy add: NADA aplicado — re-ejecuta con --yes para escribir (la confirmación es tuya, no del agente)");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
// Primer run sin .karajan/ (catch de codex): crear el padre, no reventar.
|
|
107
|
+
mkdirSync(join(projectDir, ".karajan"), { recursive: true });
|
|
108
|
+
writeFileSync(file, proposed, "utf8");
|
|
109
|
+
logger.info?.("✓ policy add: regla aplicada — los gates (review, pre-commit, tier C) la aplican desde ya");
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador del registro de decisiones para karajan-code (GOV-C,
|
|
3
|
+
* KJC-TSK-0747): almacén `.karajan/policy-decisions.jsonl`; policy_hash =
|
|
4
|
+
* sha256 del policy.yml CRUDO (null sin policy — la decisión lo dice, no
|
|
5
|
+
* lo inventa). Append no atómico entre procesos: aceptado, el chokepoint
|
|
6
|
+
* de commit es secuencial.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { recordDecision } from "@karajan-family/governance";
|
|
12
|
+
|
|
13
|
+
const filePath = (projectDir) => join(projectDir, ".karajan", "policy-decisions.jsonl");
|
|
14
|
+
|
|
15
|
+
export function policyFileHash(projectDir) {
|
|
16
|
+
try {
|
|
17
|
+
return createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "policy.yml"), "utf8"), "utf8").digest("hex");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {object} the sealed record. */
|
|
24
|
+
export function recordGateDecision(projectDir, entry) {
|
|
25
|
+
return recordDecision({
|
|
26
|
+
entry,
|
|
27
|
+
deps: {
|
|
28
|
+
lastLine: () => {
|
|
29
|
+
try {
|
|
30
|
+
const lines = readFileSync(filePath(projectDir), "utf8").split("\n").filter((l) => l.trim());
|
|
31
|
+
return lines.at(-1) ?? null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
append: (line) => {
|
|
37
|
+
mkdirSync(join(projectDir, ".karajan"), { recursive: true });
|
|
38
|
+
appendFileSync(filePath(projectDir), `${line}\n`, "utf8");
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|