karajan-code 4.26.0 → 4.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/register-meta.js +3 -0
- package/src/commands/harden.js +22 -0
- package/src/commands/policy.js +9 -1
- package/src/commands/review-gate.js +12 -0
- package/src/harden/harden-engine.js +21 -1
- package/src/harden/hook-templates.js +12 -2
- package/src/harden/sentinel-hooks.js +14 -0
- package/src/harden/supervisor-commit.js +147 -0
- package/src/policy/engine.js +3 -1
- package/src/policy/supervisor-verify.js +79 -0
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -783,10 +783,13 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
783
783
|
.option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
|
|
784
784
|
.option("--dry-run", "Show what would change without writing")
|
|
785
785
|
.option("--json", "Emit machine-readable JSON")
|
|
786
|
+
.option("--commit", "Versiona la regeneración del supervisor con procedencia sellada (acto humano, ADR 0009)")
|
|
786
787
|
.action(async (flags) => {
|
|
787
788
|
await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
|
|
788
789
|
await hardenCommand({
|
|
789
790
|
profile: flags.profile,
|
|
791
|
+
commitSupervisor: flags.commit === true,
|
|
792
|
+
kjVersion: pkgVersion,
|
|
790
793
|
config: flags.config !== false,
|
|
791
794
|
ci: flags.ci !== false,
|
|
792
795
|
guidelines: flags.guidelines !== false,
|
package/src/commands/harden.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveTestOnPush } from "../harden/test-on-push.js";
|
|
|
14
14
|
import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
|
|
15
15
|
import { interactiveHarden } from "../harden/interactive.js";
|
|
16
16
|
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
17
|
+
import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
|
|
17
18
|
import { installConfigsForRoots } from "../harden/config-engine.js";
|
|
18
19
|
import { installGuidelines } from "../harden/guidelines-engine.js";
|
|
19
20
|
import { commandsForLanguage } from "../harden/hook-commands.js";
|
|
@@ -88,6 +89,8 @@ export async function hardenCommand({
|
|
|
88
89
|
interactive = false,
|
|
89
90
|
only = [],
|
|
90
91
|
exclude = [],
|
|
92
|
+
commitSupervisor = false,
|
|
93
|
+
kjVersion = null,
|
|
91
94
|
logger = console,
|
|
92
95
|
} = {}) {
|
|
93
96
|
const repoCfg = readHardenConfig(projectDir);
|
|
@@ -199,5 +202,24 @@ export async function hardenCommand({
|
|
|
199
202
|
out.identity = id.declared ? id.identity : null;
|
|
200
203
|
if (id.pending) out.identityPending = id.pending;
|
|
201
204
|
}
|
|
205
|
+
// KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
|
|
206
|
+
// del supervisor con procedencia sellada. Acto humano — el guard vive en
|
|
207
|
+
// commitSupervisorRegeneration y rechaza sesiones de agente.
|
|
208
|
+
if (commitSupervisor && !dryRun) {
|
|
209
|
+
try {
|
|
210
|
+
out.supervisorCommit = commitSupervisorRegeneration({
|
|
211
|
+
projectDir,
|
|
212
|
+
kjVersion: kjVersion ?? "unknown",
|
|
213
|
+
generation: result.generation ?? { profile },
|
|
214
|
+
logger,
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// Fallar ALTO (catch de codex): un --commit que no commitea no puede
|
|
218
|
+
// devolver ok — quien lo pidió debe verlo, no descubrirlo después.
|
|
219
|
+
logger.error?.(`kj harden: ${err.message}`);
|
|
220
|
+
out.supervisorCommit = { committed: false, reason: err.message };
|
|
221
|
+
out.ok = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
202
224
|
return out;
|
|
203
225
|
}
|
package/src/commands/policy.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
+
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
15
16
|
import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
17
|
import { buildPolicyReport } from "../policy/report.js";
|
|
17
18
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
@@ -231,7 +232,14 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
231
232
|
// --strict (PL-C): con --strict, una violación enforcement=deny devuelve
|
|
232
233
|
// exit 2 nombrando la regla — el contrato merge-blocking del tier C.
|
|
233
234
|
const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
|
|
234
|
-
|
|
235
|
+
let violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
|
|
236
|
+
// ADR 0009 (KJC-BUG-0161): en CI la misma exención estructural que en la
|
|
237
|
+
// review — supervisor respaldado por provenance sellada + render canónico.
|
|
238
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations });
|
|
239
|
+
if (sup.lifted > 0) {
|
|
240
|
+
logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
|
|
241
|
+
violations = sup.violations;
|
|
242
|
+
}
|
|
235
243
|
const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
|
|
236
244
|
if (flags.json) {
|
|
237
245
|
logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
|
|
@@ -15,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
|
+
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
18
19
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
19
20
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
20
21
|
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
@@ -262,6 +263,17 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
262
263
|
recordException: (entry) => recordPolicyException({ projectDir, entry }),
|
|
263
264
|
standingExceptions: std.standing,
|
|
264
265
|
});
|
|
266
|
+
// ADR 0009 (KJC-BUG-0161): la exención ESTRUCTURAL del supervisor — un
|
|
267
|
+
// diff de .karajan/hooks respaldado por la provenance sellada Y el render
|
|
268
|
+
// canónico no es una edición: es la regeneración de kj harden --commit.
|
|
269
|
+
{
|
|
270
|
+
const res = liftSealedSupervisorViolations({ projectDir, violations: gate.denials });
|
|
271
|
+
if (res.lifted > 0) {
|
|
272
|
+
console.log(`✓ policy: ${res.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009) — regeneración, no edición`);
|
|
273
|
+
gate.denials = res.violations;
|
|
274
|
+
gate.ok = gate.denials.length === 0 && !gate.invalid;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
265
277
|
const at = (v) => (v.file ? ` (${v.file})` : "");
|
|
266
278
|
for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
|
|
267
279
|
for (const e of gate.exempted) console.log(formatExempted(e));
|
|
@@ -22,6 +22,24 @@ export function isGitRepo(projectDir) {
|
|
|
22
22
|
return existsSync(join(projectDir, ".git"));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* KJC-BUG-0161 / ADR 0009: el contenido CANÓNICO de un hook para una
|
|
27
|
+
* generación dada — exactamente lo que installHooks escribiría partiendo
|
|
28
|
+
* de cero. La verificación estructural del supervisor recomputa esto y
|
|
29
|
+
* compara por hash: un fichero con una sola coma manual ya no coincide.
|
|
30
|
+
*/
|
|
31
|
+
export function renderCanonicalHook(hook, generation = {}) {
|
|
32
|
+
const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
|
|
33
|
+
const { content } = upsertManagedBlock({
|
|
34
|
+
source: HOOK_PREAMBLE,
|
|
35
|
+
blockId: `hook:${hook}`,
|
|
36
|
+
version: BLOCK_VERSION,
|
|
37
|
+
body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
|
|
38
|
+
style: "hash",
|
|
39
|
+
});
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
/**
|
|
26
44
|
* Install (or refresh) the profile's hooks under `.karajan/hooks/` and point
|
|
27
45
|
* `core.hooksPath` there. Idempotent. `dryRun` computes actions without
|
|
@@ -76,5 +94,7 @@ export async function installHooks({
|
|
|
76
94
|
await runCommand("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: projectDir });
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
|
|
97
|
+
// KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
|
|
98
|
+
// provenance necesita registrar para que CI pueda recomputar y comparar.
|
|
99
|
+
return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
|
|
80
100
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
11
13
|
const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
|
|
12
14
|
const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
|
|
13
15
|
|
|
@@ -27,10 +29,18 @@ export const PROFILE_HOOKS = {
|
|
|
27
29
|
// guarded, so machines without it are unaffected.
|
|
28
30
|
function chainToGlobal(hook, globalHooksDir) {
|
|
29
31
|
if (!globalHooksDir) return [];
|
|
32
|
+
// KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
|
|
33
|
+
// absolute home path — resolve through $HOME at runtime so the same
|
|
34
|
+
// generated content is valid on every machine and its provenance stays
|
|
35
|
+
// verifiable (and no local filesystem layout leaks into a public repo).
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
|
|
38
|
+
? `$HOME${globalHooksDir.slice(home.length)}`
|
|
39
|
+
: globalHooksDir;
|
|
30
40
|
return [
|
|
31
41
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
32
|
-
`if [ -x "${
|
|
33
|
-
` "${
|
|
42
|
+
`if [ -x "${dir}/${hook}" ]; then`,
|
|
43
|
+
` "${dir}/${hook}" "$@"; rc=$?`,
|
|
34
44
|
' [ "$rc" -eq 0 ] || exit "$rc"',
|
|
35
45
|
"fi",
|
|
36
46
|
];
|
|
@@ -371,6 +371,20 @@ process.stdin.on("end", () => {
|
|
|
371
371
|
console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
|
|
372
372
|
process.exit(2);
|
|
373
373
|
}
|
|
374
|
+
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
375
|
+
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
376
|
+
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
377
|
+
// (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
|
|
378
|
+
// ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
|
|
379
|
+
// arriba: un env var lo pone tambien un agente — el supervisor no se
|
|
380
|
+
// apaga con interruptores al alcance del supervisado.
|
|
381
|
+
{
|
|
382
|
+
const kjCmd = String(input.command || "");
|
|
383
|
+
if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
|
|
384
|
+
console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
|
|
385
|
+
process.exit(2);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
374
388
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
375
389
|
// KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
|
|
376
390
|
// (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0161 / ADR 0009 (opción A) — `kj harden --commit`, el cauce
|
|
3
|
+
* sancionado del supervisor: ACTO HUMANO que versiona la regeneración con
|
|
4
|
+
* provenance trackeada (versión, parámetros, sha256), sello en el acta y
|
|
5
|
+
* commit quirúrgico. El --no-verify es a conciencia: lo que salta en local
|
|
6
|
+
* lo re-verifica CI contra la provenance (pieza 3).
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
|
+
import { readIdentity } from "../identity/store.js";
|
|
15
|
+
|
|
16
|
+
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
17
|
+
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
18
|
+
|
|
19
|
+
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
20
|
+
|
|
21
|
+
// Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
|
|
22
|
+
// isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
|
|
23
|
+
// de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
|
|
24
|
+
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
25
|
+
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
|
|
26
|
+
const read = readProc || ((p) => {
|
|
27
|
+
const stat = readFileSync(`/proc/${p}/stat`, "utf8");
|
|
28
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
29
|
+
let cmd = "";
|
|
30
|
+
try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
|
|
31
|
+
return { ppid, cmd };
|
|
32
|
+
});
|
|
33
|
+
let cur = pid;
|
|
34
|
+
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
35
|
+
let info;
|
|
36
|
+
try { info = read(cur); } catch { return { agent: false, unknown: true }; }
|
|
37
|
+
if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
|
|
38
|
+
if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
|
|
39
|
+
cur = info.ppid;
|
|
40
|
+
}
|
|
41
|
+
return { agent: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
|
|
45
|
+
// atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
|
|
46
|
+
function defaultConfirm(nonce) {
|
|
47
|
+
process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
|
|
48
|
+
try {
|
|
49
|
+
const buf = Buffer.alloc(64);
|
|
50
|
+
const fd = openSync("/dev/tty", "r");
|
|
51
|
+
const n = readSync(fd, buf, 0, 64);
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
return buf.toString("utf8", 0, n).trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
60
|
+
export function supervisorDrift({ projectDir, gitFn }) {
|
|
61
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
62
|
+
// Un rename sale como "R old -> new" (catch de codex): ambas rutas son
|
|
63
|
+
// drift — la vieja se versiona como borrado y la nueva con su hash.
|
|
64
|
+
return run(["status", "--porcelain", "--", ".karajan/hooks"])
|
|
65
|
+
.split("\n")
|
|
66
|
+
.flatMap((l) => l.slice(3).trim().split(" -> "))
|
|
67
|
+
.filter((f) => f.startsWith(HOOKS_PREFIX));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @returns {{committed: boolean, reason?: string, files?: object[]}}
|
|
72
|
+
*/
|
|
73
|
+
export function commitSupervisorRegeneration({
|
|
74
|
+
projectDir,
|
|
75
|
+
kjVersion,
|
|
76
|
+
generation,
|
|
77
|
+
logger = console,
|
|
78
|
+
gitFn = null,
|
|
79
|
+
env = process.env,
|
|
80
|
+
tty = process.stdout.isTTY,
|
|
81
|
+
deps = {},
|
|
82
|
+
}) {
|
|
83
|
+
// El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
|
|
84
|
+
if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const anc = agentAncestry(deps.ancestry ?? {});
|
|
90
|
+
if (anc.agent) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
96
|
+
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
97
|
+
// Un alimentador ciego no conoce el código; automatizar su lectura exige
|
|
98
|
+
// un driver expect — premeditación inequívoca, no un descuido.
|
|
99
|
+
const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
|
|
100
|
+
const answer = (deps.confirm ?? defaultConfirm)(nonce);
|
|
101
|
+
if (answer !== nonce) {
|
|
102
|
+
throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
103
|
+
}
|
|
104
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
105
|
+
const files = supervisorDrift({ projectDir, gitFn: run });
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
logger.info?.("harden --commit: sin drift en el supervisor — nada que versionar");
|
|
108
|
+
return { committed: false, reason: "sin drift" };
|
|
109
|
+
}
|
|
110
|
+
// Borrados/renombrados también son drift (catch de codex): ausente ⇒ deleted.
|
|
111
|
+
const hashed = files.map((file) => {
|
|
112
|
+
const abs = join(projectDir, file);
|
|
113
|
+
return existsSync(abs) ? { file, sha256: sha256(abs) } : { file, deleted: true };
|
|
114
|
+
});
|
|
115
|
+
const who = readIdentity(projectDir);
|
|
116
|
+
const provenance = {
|
|
117
|
+
kj_version: kjVersion,
|
|
118
|
+
generated_at: new Date().toISOString(),
|
|
119
|
+
generation,
|
|
120
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
121
|
+
files: hashed,
|
|
122
|
+
};
|
|
123
|
+
writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
|
124
|
+
recordGateDecision(projectDir, {
|
|
125
|
+
decision: "exempt",
|
|
126
|
+
chokepoint: "supervisor",
|
|
127
|
+
kind: "supervisor-regeneration",
|
|
128
|
+
kj_version: kjVersion,
|
|
129
|
+
files: hashed,
|
|
130
|
+
who: provenance.who,
|
|
131
|
+
});
|
|
132
|
+
// Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
|
|
133
|
+
// sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
|
|
134
|
+
// supervisor versiona altas, cambios, borrados y renombrados por igual.
|
|
135
|
+
run(["add", "-A", "--", PROVENANCE_FILE, ".karajan/hooks"]);
|
|
136
|
+
run([
|
|
137
|
+
"commit",
|
|
138
|
+
"--no-verify",
|
|
139
|
+
"-m",
|
|
140
|
+
`chore(harden): supervisor regenerated and sealed by kj harden v${kjVersion}`,
|
|
141
|
+
"--",
|
|
142
|
+
PROVENANCE_FILE,
|
|
143
|
+
".karajan/hooks",
|
|
144
|
+
]);
|
|
145
|
+
logger.info?.(`harden --commit: ${files.length} fichero(s) de supervisor versionados con procedencia sellada`);
|
|
146
|
+
return { committed: true, files: hashed };
|
|
147
|
+
}
|
package/src/policy/engine.js
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
export const SUPERVISOR_DEFAULTS = [
|
|
26
26
|
{
|
|
27
27
|
id: "defaults.supervisor.write",
|
|
28
|
-
|
|
28
|
+
// ADR 0009: la provenance del supervisor es superficie de supervisor —
|
|
29
|
+
// un agente no la escribe; solo kj harden --commit (humano) la produce.
|
|
30
|
+
pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\/|\.karajan\/supervisor-provenance\.json\b/,
|
|
29
31
|
message: "nombra ficheros del supervisor — solo el humano los modifica, fuera de la sesión",
|
|
30
32
|
},
|
|
31
33
|
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0161 / ADR 0009, pieza 3 — exención ESTRUCTURAL del supervisor:
|
|
3
|
+
* un fichero de .karajan/hooks solo deja de ser violación si su contenido
|
|
4
|
+
* coincide (sha256) con la provenance sellada Y con el render canónico
|
|
5
|
+
* recomputado. No confía: recomputa. Una coma manual sigue denegada.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve, sep } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { renderCanonicalHook } from "../harden/harden-engine.js";
|
|
12
|
+
import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
13
|
+
|
|
14
|
+
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
15
|
+
// globalHooksDir es el único texto libre que se interpola en el render:
|
|
16
|
+
// vocabulario cerrado de ruta o la provenance no verifica NADA.
|
|
17
|
+
const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
|
|
18
|
+
|
|
19
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
|
|
23
|
+
* supervisor cuyo estado actual está respaldado por la provenance sellada.
|
|
24
|
+
*/
|
|
25
|
+
export function verifiedSupervisorFiles({ projectDir }) {
|
|
26
|
+
let prov;
|
|
27
|
+
try {
|
|
28
|
+
prov = JSON.parse(readFileSync(resolve(projectDir, PROVENANCE_FILE), "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return { files: new Set(), reason: "sin provenance" };
|
|
31
|
+
}
|
|
32
|
+
const gen = prov?.generation ?? {};
|
|
33
|
+
if (gen.globalHooksDir != null && !SAFE_DIR.test(gen.globalHooksDir)) {
|
|
34
|
+
return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
|
|
35
|
+
}
|
|
36
|
+
const ok = new Set();
|
|
37
|
+
const entries = Array.isArray(prov?.files) ? prov.files : [];
|
|
38
|
+
const hooksRoot = resolve(projectDir, HOOKS_PREFIX);
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (typeof entry?.file !== "string" || !entry.file.startsWith(HOOKS_PREFIX)) continue;
|
|
41
|
+
// Contención por resolución, no por prefijo textual (catch de codex).
|
|
42
|
+
const abs = resolve(projectDir, entry.file);
|
|
43
|
+
if (!abs.startsWith(hooksRoot + sep)) continue;
|
|
44
|
+
let st = null;
|
|
45
|
+
try { st = lstatSync(abs); } catch { /* no hay objeto en la ruta */ }
|
|
46
|
+
if (entry.deleted === true) {
|
|
47
|
+
if (st === null) ok.add(entry.file);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (typeof entry.sha256 !== "string" || !st?.isFile()) continue;
|
|
51
|
+
if (sha256(readFileSync(abs)) !== entry.sha256) continue;
|
|
52
|
+
let canonical;
|
|
53
|
+
try {
|
|
54
|
+
canonical = renderCanonicalHook(entry.file.slice(HOOKS_PREFIX.length), gen);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
|
|
59
|
+
}
|
|
60
|
+
// `complete` = CADA entrada verificó: solo entonces la provenance se
|
|
61
|
+
// describe con honestidad y su propio diff puede alzarse.
|
|
62
|
+
const complete = entries.length > 0 && entries.every((e) => typeof e?.file === "string" && ok.has(e.file));
|
|
63
|
+
return { files: ok, complete };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Filtra violaciones de supervisor respaldadas — {violations, lifted}. */
|
|
67
|
+
export function liftSealedSupervisorViolations({ projectDir, violations }) {
|
|
68
|
+
const RULE = "defaults.supervisor.write";
|
|
69
|
+
if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
|
|
70
|
+
const sealed = verifiedSupervisorFiles({ projectDir });
|
|
71
|
+
// La violación sobre la PROPIA provenance solo se alza si la provenance
|
|
72
|
+
// entera verificó — a medias no describe la verdad y sigue denegada.
|
|
73
|
+
const liftable = (v) =>
|
|
74
|
+
v.rule_id === RULE
|
|
75
|
+
&& v.file
|
|
76
|
+
&& (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
|
|
77
|
+
const kept = violations.filter((v) => !liftable(v));
|
|
78
|
+
return { violations: kept, lifted: violations.length - kept.length };
|
|
79
|
+
}
|