karajan-code 4.27.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 +2 -1
- package/src/cli/register-meta.js +9 -0
- package/src/commands/harden.js +1 -1
- package/src/commands/identity.js +13 -0
- package/src/harden/hook-templates.js +8 -0
- package/src/harden/phone-sign.js +119 -0
- package/src/harden/sentinel-hooks.js +47 -0
- package/src/harden/supervisor-commit.js +35 -13
- package/src/harden/workflow-templates.js +9 -1
- package/src/review/gate-gitignore.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
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",
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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.
|
package/src/commands/harden.js
CHANGED
|
@@ -207,7 +207,7 @@ export async function hardenCommand({
|
|
|
207
207
|
// commitSupervisorRegeneration y rechaza sesiones de agente.
|
|
208
208
|
if (commitSupervisor && !dryRun) {
|
|
209
209
|
try {
|
|
210
|
-
out.supervisorCommit = commitSupervisorRegeneration({
|
|
210
|
+
out.supervisorCommit = await commitSupervisorRegeneration({
|
|
211
211
|
projectDir,
|
|
212
212
|
kjVersion: kjVersion ?? "unknown",
|
|
213
213
|
generation: result.generation ?? { profile },
|
package/src/commands/identity.js
CHANGED
|
@@ -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) {
|
|
@@ -109,6 +109,14 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
109
109
|
if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
|
|
110
110
|
if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
|
|
111
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",
|
|
112
120
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
113
121
|
"# a staged diff only enters with a recorded cross-AI approved verdict.",
|
|
114
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,44 @@ 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
|
+
}
|
|
374
412
|
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
375
413
|
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
376
414
|
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
@@ -578,6 +616,15 @@ process.stdin.on("end", () => {
|
|
|
578
616
|
}
|
|
579
617
|
if (tool === "Bash") {
|
|
580
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
|
+
}
|
|
581
628
|
// Deny-unless-known-read-only: un allowlist de verbos mutadores era
|
|
582
629
|
// esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
|
|
583
630
|
// encadenar ni redirigir) puede nombrar otro carril; leer carriles es
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { closeSync, existsSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
14
|
import { readIdentity } from "../identity/store.js";
|
|
15
|
+
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
15
16
|
|
|
16
17
|
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
17
18
|
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
@@ -68,9 +69,9 @@ export function supervisorDrift({ projectDir, gitFn }) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* @returns {{committed: boolean, reason?: string, files?: object[]}}
|
|
72
|
+
* @returns {Promise<{committed: boolean, reason?: string, files?: object[]}>}
|
|
72
73
|
*/
|
|
73
|
-
export function commitSupervisorRegeneration({
|
|
74
|
+
export async function commitSupervisorRegeneration({
|
|
74
75
|
projectDir,
|
|
75
76
|
kjVersion,
|
|
76
77
|
generation,
|
|
@@ -102,16 +103,37 @@ export function commitSupervisorRegeneration({
|
|
|
102
103
|
throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
103
104
|
}
|
|
104
105
|
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
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");
|
|
108
120
|
return { committed: false, reason: "sin drift" };
|
|
109
121
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
})
|
|
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
|
+
}
|
|
115
137
|
const who = readIdentity(projectDir);
|
|
116
138
|
const provenance = {
|
|
117
139
|
kj_version: kjVersion,
|
|
@@ -142,6 +164,6 @@ export function commitSupervisorRegeneration({
|
|
|
142
164
|
PROVENANCE_FILE,
|
|
143
165
|
".karajan/hooks",
|
|
144
166
|
]);
|
|
145
|
-
logger.info?.(`harden --commit: ${
|
|
167
|
+
logger.info?.(`harden --commit: ${hashed.length} fichero(s) de supervisor versionados con procedencia sellada`);
|
|
146
168
|
return { committed: true, files: hashed };
|
|
147
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,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
|
/**
|