karajan-code 4.26.0 → 4.28.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.26.0",
3
+ "version": "4.28.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -116,6 +116,7 @@
116
116
  "karajan-rag": "^1.2.0",
117
117
  "knip": "^6.15.0",
118
118
  "madge": "^8.0.0",
119
+ "qrcode-terminal": "0.12.0",
119
120
  "sqlite-vec": "^0.1.9",
120
121
  "valibot": "^1.4.1",
121
122
  "web-tree-sitter": "^0.26.9",
@@ -148,6 +148,15 @@ export function registerMeta(program, { pkgVersion }) {
148
148
  process.exitCode = await identityCommand({ action: "set", config, flags });
149
149
  });
150
150
  });
151
+ identityCmd
152
+ .command("enroll-phone")
153
+ .description("Enroll the phone's PUBLIC signing key (base64 of the raw 32-byte ed25519 key) for the supervisor seal")
154
+ .argument("<publicKeyBase64>", "raw ed25519 public key, base64 (shown by the phone app)")
155
+ .action(async (publicKeyBase64, flags) => {
156
+ await withConfig(pkgVersion, "identity-enroll-phone", flags, async ({ config }) => {
157
+ process.exitCode = await identityCommand({ action: "enroll-phone", config, flags: { ...flags, publicKeyBase64 } });
158
+ });
159
+ });
151
160
 
152
161
  // MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
153
162
  // question, and the person lands inside a governed conversation.
@@ -783,10 +792,13 @@ export function registerMeta(program, { pkgVersion }) {
783
792
  .option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
784
793
  .option("--dry-run", "Show what would change without writing")
785
794
  .option("--json", "Emit machine-readable JSON")
795
+ .option("--commit", "Versiona la regeneración del supervisor con procedencia sellada (acto humano, ADR 0009)")
786
796
  .action(async (flags) => {
787
797
  await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
788
798
  await hardenCommand({
789
799
  profile: flags.profile,
800
+ commitSupervisor: flags.commit === true,
801
+ kjVersion: pkgVersion,
790
802
  config: flags.config !== false,
791
803
  ci: flags.ci !== false,
792
804
  guidelines: flags.guidelines !== false,
@@ -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 = await 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
  }
@@ -8,6 +8,7 @@
8
8
  * no session and no flags → exit 1.
9
9
  */
10
10
 
11
+ import { enrollPhone } from "../harden/phone-sign.js";
11
12
  import { readIdentity, writeIdentity } from "../identity/store.js";
12
13
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
13
14
  import { compareIdentity } from "../identity/compare.js";
@@ -23,6 +24,18 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
23
24
  git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
24
25
  };
25
26
 
27
+ // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
28
+ if (action === "enroll-phone") {
29
+ try {
30
+ enrollPhone(flags.publicKeyBase64, { home: deps.home });
31
+ } catch (err) {
32
+ log(`kj identity enroll-phone: ${err.message}`);
33
+ return 1;
34
+ }
35
+ log("Móvil enrolado: la clave pública quedó en ~/.karajan/supervisor-phone.json. A partir de ahora, sellar el supervisor pedirá la firma de tu móvil.");
36
+ return 0;
37
+ }
38
+
26
39
  if (action === "set") {
27
40
  const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
28
41
  if (!candidate.gh_user || !candidate.git_email) {
@@ -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
- const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
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
- return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results };
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 "${globalHooksDir}/${hook}" ]; then`,
33
- ` "${globalHooksDir}/${hook}" "$@"; rc=$?`,
42
+ `if [ -x "${dir}/${hook}" ]; then`,
43
+ ` "${dir}/${hook}" "$@"; rc=$?`,
34
44
  ' [ "$rc" -eq 0 ] || exit "$rc"',
35
45
  "fi",
36
46
  ];
@@ -99,6 +109,14 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
99
109
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
100
110
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
101
111
  lines.push(
112
+ "# KJC-BUG-0164: attribution is surface EVERYWHERE — the staged diff's",
113
+ "# ADDED lines (changelog, docs, code comments) are scanned too, with",
114
+ "# the same precise pattern (tool MENTIONS stay legal; attribution not).",
115
+ "# The guard's own definition files are excluded by literal path — they",
116
+ "# CONTAIN the pattern; excluding a path that does not exist is harmless.",
117
+ `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
118
+ " echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
119
+ "fi",
102
120
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
103
121
  "# a staged diff only enters with a recorded cross-AI approved verdict.",
104
122
  "if [ -f .karajan/review-gate ]; then",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * KJC-TSK-0822 (PRP-0023) — capa 5 del sello del supervisor: firma asimétrica
3
+ * desde el móvil. La clave privada vive SOLO en el teléfono; aquí se enrola su
4
+ * clave PÚBLICA (~/.karajan/supervisor-phone.json) y se verifica ed25519
5
+ * contra ella — el transporte (Firestore, PR siguiente) nunca es la verdad.
6
+ */
7
+ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import qrcode from "qrcode-terminal";
13
+
14
+ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
15
+ // La config del relé (apiKey de cliente Firestore — pública por diseño, la
16
+ // misma que la página firmante lleva incrustada) NO viaja en el tarball: el
17
+ // escáner de privacidad del pack deniega claves google con razón general, y
18
+ // servirla desde la landing la hace además rotable sin release.
19
+ const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
20
+ let relayCache = null;
21
+ async function relayConfig(fetchFn) {
22
+ if (relayCache) return relayCache;
23
+ const res = await fetchFn(RELAY_CONFIG_URL);
24
+ if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
25
+ const cfg = await res.json();
26
+ if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
27
+ relayCache = {
28
+ apiKey: cfg.apiKey,
29
+ url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
30
+ };
31
+ return relayCache;
32
+ }
33
+ const SIGN_PAGE = "https://karajancode.com/sign";
34
+ const POLL_INTERVAL_MS = 2000;
35
+ const TTL_MS = 120000;
36
+
37
+ const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
38
+
39
+ export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
40
+
41
+ export const readEnrolledKey = ({ home } = {}) =>
42
+ JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
43
+
44
+ /** Valida y persiste la clave pública del móvil (base64 del raw de 32 bytes). */
45
+ export function enrollPhone(publicKeyBase64, { home } = {}) {
46
+ const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
47
+ if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
48
+ throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
49
+ }
50
+ mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
51
+ const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
52
+ writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
53
+ }
54
+
55
+ /** Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios. */
56
+ export function canonicalPayload({ cid, nonce, project, files }) {
57
+ const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
58
+ return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
59
+ }
60
+
61
+ /** Verifica la firma (base64) del payload contra la clave pública raw (base64). */
62
+ export function verifyPhoneSignature({ payload, signature, publicKey }) {
63
+ const der = Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(publicKey, "base64")]);
64
+ const key = createPublicKey({ key: der, format: "der", type: "spki" });
65
+ return verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"));
66
+ }
67
+
68
+ /**
69
+ * Publica la petición en Firestore, enseña QR + URL, pollea el doc y verifica
70
+ * la firma. Sin fallbacks silenciosos: red caída o HTTP no-ok ⇒ throw. Firma
71
+ * mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
72
+ * @returns {Promise<{ok: boolean, reason?: string}>}
73
+ */
74
+ export async function requestPhoneSignature({ project, files, kjVersion, logger = console, deps = {} }) {
75
+ const fetchFn = deps.fetch ?? fetch;
76
+ const now = deps.now ?? Date.now;
77
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
78
+ const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
79
+ const cid = randomBytes(16).toString("hex");
80
+ const nonce = randomBytes(16).toString("hex");
81
+ const body = {
82
+ fields: {
83
+ nonce: { stringValue: nonce },
84
+ project: { stringValue: project },
85
+ kj_version: { stringValue: kjVersion },
86
+ state: { stringValue: "pending" },
87
+ createdAt: { timestampValue: new Date().toISOString() },
88
+ files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
89
+ },
90
+ };
91
+ const relay = await relayConfig(fetchFn);
92
+ const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
93
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
94
+ });
95
+ if (!created.ok) {
96
+ throw new Error(`phone-sign: no se pudo publicar la petición de firma (HTTP ${created.status}) — sin red no hay sello, no se degrada`);
97
+ }
98
+ const signUrl = `${SIGN_PAGE}?c=${cid}`;
99
+ drawQr(signUrl);
100
+ logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
101
+ const deadline = now() + TTL_MS;
102
+ while (now() < deadline) {
103
+ const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
104
+ if (!res.ok) throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`);
105
+ const fields = (await res.json()).fields ?? {};
106
+ if (fields.state?.stringValue === "signed") {
107
+ const enrolled = readEnrolledKey({ home: deps.home });
108
+ if (fields.publicKey?.stringValue !== enrolled) {
109
+ return { ok: false, reason: "la publicKey del doc no coincide con la enrolada — el doc es transporte, la verdad es la enrolada" };
110
+ }
111
+ const payload = canonicalPayload({ cid, nonce, project, files });
112
+ const good = verifyPhoneSignature({ payload, signature: fields.signature?.stringValue ?? "", publicKey: enrolled });
113
+ return good ? { ok: true } : { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
114
+ }
115
+ logger.info?.("phone-sign: esperando la firma del móvil…");
116
+ await sleep(POLL_INTERVAL_MS);
117
+ }
118
+ return { ok: false, reason: "caducado" };
119
+ }
@@ -371,6 +371,58 @@ 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
+ // KJC-BUG-0164: todo gh que PUBLICA texto (pr/issue/release, create/edit/
375
+ // comment/review) se escanea contra el patron de ATRIBUCION a IA antes de
376
+ // salir — comando inline Y ficheros --body-file/--notes-file. Regla
377
+ // determinista del proyecto: prohibido, sin escape. Un fichero ilegible
378
+ // tampoco publica (fail closed).
379
+ {
380
+ // Regex SIN backslashes a proposito (clases de caracteres, emoji
381
+ // literal): asi la plantilla y el fichero generado son identicos y no
382
+ // hay doble-escape que leer mal.
383
+ const ghCmd = String(input.command || "");
384
+ if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
385
+ const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
386
+ let corpus = ghCmd;
387
+ for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
388
+ const bodyPath = m[2] || m[3] || m[4];
389
+ try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
390
+ console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
391
+ process.exit(2);
392
+ }
393
+ }
394
+ if (attrib.test(corpus)) {
395
+ console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
396
+ process.exit(2);
397
+ }
398
+ }
399
+ }
400
+ // KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
401
+ // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
402
+ // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
403
+ {
404
+ const enrollCmd = String(input.command || "");
405
+ const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
406
+ const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
407
+ if ((tool === "Bash" && namesEnroll) || writesEnroll) {
408
+ console.error("karajan sentinel: el enrolamiento del movil (capa 5) es un acto humano — pideselo a tu usuario; supervisor-phone.json no se toca desde la sesion." + doc("supervisor"));
409
+ process.exit(2);
410
+ }
411
+ }
412
+ // ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
413
+ // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
414
+ // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
415
+ // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
416
+ // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
417
+ // arriba: un env var lo pone tambien un agente — el supervisor no se
418
+ // apaga con interruptores al alcance del supervisado.
419
+ {
420
+ const kjCmd = String(input.command || "");
421
+ if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
422
+ 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"));
423
+ process.exit(2);
424
+ }
425
+ }
374
426
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
375
427
  // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
376
428
  // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
@@ -564,6 +616,15 @@ process.stdin.on("end", () => {
564
616
  }
565
617
  if (tool === "Bash") {
566
618
  const cmd = String(input.command || "");
619
+ // KJC-BUG-0166: una sesion intento EN BUCLE retirar el ARBOL PRINCIPAL
620
+ // con git worktree remove y KJ_ALLOW_CROSS_LANE puesto. Retirar un
621
+ // worktree es destruccion de la estructura del repo — jamas trabajo de
622
+ // una sesion, y el escape de cruce NO puede ampararlo. Deny sin escape
623
+ // (el cauce sancionado es kj worktree done, que corre dentro de kj).
624
+ if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(remove|prune)/.test(cmd)) {
625
+ console.error("karajan sentinel: git worktree remove/prune no se ejecuta desde una sesion — destruccion de estructura del repo, sin escape; usa kj worktree done." + doc("cross-lane"));
626
+ process.exit(2);
627
+ }
567
628
  // Deny-unless-known-read-only: un allowlist de verbos mutadores era
568
629
  // esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
569
630
  // encadenar ni redirigir) puede nombrar otro carril; leer carriles es
@@ -0,0 +1,169 @@
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, readdirSync, writeFileSync } from "node:fs";
11
+ import { basename, join } from "node:path";
12
+
13
+ import { recordGateDecision } from "../policy/decisions.js";
14
+ import { readIdentity } from "../identity/store.js";
15
+ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
16
+
17
+ export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
18
+ const HOOKS_PREFIX = ".karajan/hooks/";
19
+
20
+ const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
21
+
22
+ // Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
23
+ // isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
24
+ // de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
25
+ const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
26
+ export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
27
+ const read = readProc || ((p) => {
28
+ const stat = readFileSync(`/proc/${p}/stat`, "utf8");
29
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
30
+ let cmd = "";
31
+ try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
32
+ return { ppid, cmd };
33
+ });
34
+ let cur = pid;
35
+ for (let i = 0; i < maxDepth && cur > 1; i += 1) {
36
+ let info;
37
+ try { info = read(cur); } catch { return { agent: false, unknown: true }; }
38
+ if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
39
+ if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
40
+ cur = info.ppid;
41
+ }
42
+ return { agent: false };
43
+ }
44
+
45
+ // Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
46
+ // atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
47
+ function defaultConfirm(nonce) {
48
+ process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
49
+ try {
50
+ const buf = Buffer.alloc(64);
51
+ const fd = openSync("/dev/tty", "r");
52
+ const n = readSync(fd, buf, 0, 64);
53
+ closeSync(fd);
54
+ return buf.toString("utf8", 0, n).trim();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
61
+ export function supervisorDrift({ projectDir, gitFn }) {
62
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
63
+ // Un rename sale como "R old -> new" (catch de codex): ambas rutas son
64
+ // drift — la vieja se versiona como borrado y la nueva con su hash.
65
+ return run(["status", "--porcelain", "--", ".karajan/hooks"])
66
+ .split("\n")
67
+ .flatMap((l) => l.slice(3).trim().split(" -> "))
68
+ .filter((f) => f.startsWith(HOOKS_PREFIX));
69
+ }
70
+
71
+ /**
72
+ * @returns {Promise<{committed: boolean, reason?: string, files?: object[]}>}
73
+ */
74
+ export async function commitSupervisorRegeneration({
75
+ projectDir,
76
+ kjVersion,
77
+ generation,
78
+ logger = console,
79
+ gitFn = null,
80
+ env = process.env,
81
+ tty = process.stdout.isTTY,
82
+ deps = {},
83
+ }) {
84
+ // El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
85
+ if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
86
+ throw new Error(
87
+ "harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
88
+ );
89
+ }
90
+ const anc = agentAncestry(deps.ancestry ?? {});
91
+ if (anc.agent) {
92
+ throw new Error(
93
+ `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)`,
94
+ );
95
+ }
96
+ // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
97
+ // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
98
+ // Un alimentador ciego no conoce el código; automatizar su lectura exige
99
+ // un driver expect — premeditación inequívoca, no un descuido.
100
+ const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
101
+ const answer = (deps.confirm ?? defaultConfirm)(nonce);
102
+ if (answer !== nonce) {
103
+ throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
104
+ }
105
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
106
+ const drift = supervisorDrift({ projectDir, gitFn: run });
107
+ // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
108
+ // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
109
+ // ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
110
+ const current = readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f).sort();
111
+ const hashed = [
112
+ ...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
113
+ ...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
114
+ ];
115
+ let previous = null;
116
+ try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }
117
+ const covered = JSON.stringify(previous?.files ?? null) === JSON.stringify(hashed);
118
+ if (drift.length === 0 && covered) {
119
+ logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
120
+ return { committed: false, reason: "sin drift" };
121
+ }
122
+ // Capa 5 (KJC-TSK-0822 / PRP-0023): con móvil enrolado la firma asimétrica
123
+ // es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
124
+ // files/hashes de la provenance (normalizados al par {file, sha256}).
125
+ const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
126
+ if (phone.enrolled({})) {
127
+ const signed = await phone.request({
128
+ project: basename(projectDir),
129
+ files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
130
+ kjVersion,
131
+ logger,
132
+ });
133
+ if (!signed.ok) {
134
+ throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
135
+ }
136
+ }
137
+ const who = readIdentity(projectDir);
138
+ const provenance = {
139
+ kj_version: kjVersion,
140
+ generated_at: new Date().toISOString(),
141
+ generation,
142
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
143
+ files: hashed,
144
+ };
145
+ writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
146
+ recordGateDecision(projectDir, {
147
+ decision: "exempt",
148
+ chokepoint: "supervisor",
149
+ kind: "supervisor-regeneration",
150
+ kj_version: kjVersion,
151
+ files: hashed,
152
+ who: provenance.who,
153
+ });
154
+ // Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
155
+ // sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
156
+ // supervisor versiona altas, cambios, borrados y renombrados por igual.
157
+ run(["add", "-A", "--", PROVENANCE_FILE, ".karajan/hooks"]);
158
+ run([
159
+ "commit",
160
+ "--no-verify",
161
+ "-m",
162
+ `chore(harden): supervisor regenerated and sealed by kj harden v${kjVersion}`,
163
+ "--",
164
+ PROVENANCE_FILE,
165
+ ".karajan/hooks",
166
+ ]);
167
+ logger.info?.(`harden --commit: ${hashed.length} fichero(s) de supervisor versionados con procedencia sellada`);
168
+ return { committed: true, files: hashed };
169
+ }
@@ -36,9 +36,14 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
36
36
  ` - uses: ${PINNED_ACTIONS.checkout}`,
37
37
  " with:",
38
38
  " fetch-depth: 0",
39
- " - name: Scan commit messages for AI attribution",
39
+ " - name: Scan commit messages and PR body for AI attribution",
40
40
  " env:",
41
41
  " BASE_REF: ${{ github.base_ref }}",
42
+ // KJC-BUG-0164: el cuerpo de la PR tambien es superficie — 15 PRs entraron
43
+ // con pie de atribucion porque solo se escaneaban commits. Via env, jamas
44
+ // interpolado en el script (el cuerpo es texto no confiable).
45
+ " PR_BODY: ${{ github.event.pull_request.body }}",
46
+ " PR_TITLE: ${{ github.event.pull_request.title }}",
42
47
  " run: |",
43
48
  ' if ! msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"); then',
44
49
  ' echo "::error::cannot resolve origin/${BASE_REF}...HEAD — the scan did not run"; exit 1',
@@ -48,6 +53,9 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
48
53
  ' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
49
54
  ' echo "::error::AI attribution detected in commit messages"; exit 1',
50
55
  " fi",
56
+ ' if printf "%s\\n%s" "$PR_BODY" "$PR_TITLE" | grep -qiE "$pat|generated with \\[?claude|🤖"; then',
57
+ ' echo "::error::AI attribution detected in the PR body or title"; exit 1',
58
+ " fi",
51
59
  ' echo "AI attribution scan: clean"',
52
60
  ].join("\n");
53
61
 
@@ -25,7 +25,9 @@ import {
25
25
  export const SUPERVISOR_DEFAULTS = [
26
26
  {
27
27
  id: "defaults.supervisor.write",
28
- pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\//,
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
+ }
@@ -25,7 +25,11 @@ export const CONTRACT_BLOCK = [
25
25
  "!.karajan/hooks/commit-msg",
26
26
  "!.karajan/hooks/pre-push",
27
27
  "!.karajan/hooks/post-merge",
28
+ "!.karajan/hooks/prepare-commit-msg",
28
29
  "!.karajan/adrs/",
30
+ // ADR 0009 (KJC-BUG-0161): la procedencia del supervisor VIAJA con el
31
+ // repo — es lo que CI verifica. Cazado en el primer harden --commit real.
32
+ "!.karajan/supervisor-provenance.json",
29
33
  ];
30
34
 
31
35
  /**