karajan-code 4.46.0 → 4.47.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.46.0",
3
+ "version": "4.47.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",
@@ -35,7 +35,7 @@ export const ADVANCED_GROUPS = [
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
36
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
37
37
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "claims", "steward", "pr-size"] },
38
- { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
38
+ { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
41
41
  ];
@@ -8,6 +8,7 @@ import { goCommand } from "../commands/go.js";
8
8
  import { bootstrapCommand } from "../commands/bootstrap.js";
9
9
  import { PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
10
10
  import { identityCommand } from "../commands/identity.js";
11
+ import { securityCommand } from "../commands/security.js";
11
12
  import { policyCommand } from "../commands/policy.js";
12
13
  import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
13
14
  import { stewardSweepCommand } from "../commands/steward.js";
@@ -163,12 +164,35 @@ export function registerMeta(program, { pkgVersion }) {
163
164
  .command("enroll-phone")
164
165
  .description("Enroll the phone's PUBLIC signing key (base64 of the raw 32-byte ed25519 key) for the supervisor seal")
165
166
  .argument("<publicKeyBase64>", "raw ed25519 public key, base64 (shown by the phone app)")
167
+ .option("--label <name>", "A name for this phone in the roster")
168
+ .option("--recovery <code>", "Replace a lost phone with the recovery code shown at enrollment (spent on use, a new one is issued)")
169
+ .option("--replaces <publicKeyBase64>", "With --recovery: the lost phone to revoke (default: the one enrolled on this machine)")
166
170
  .action(async (publicKeyBase64, flags) => {
167
171
  await withConfig(pkgVersion, "identity-enroll-phone", flags, async ({ config }) => {
168
- process.exitCode = await identityCommand({ action: "enroll-phone", config, flags: { ...flags, publicKeyBase64 } });
172
+ process.exitCode = await identityCommand({ action: "enroll-phone", config, kjVersion: pkgVersion, flags: { ...flags, publicKeyBase64 } });
169
173
  });
170
174
  });
171
175
 
176
+ // KJC-TSK-0995 (HUM-D, ADR 0018): the project's security level; going down is a signed act.
177
+ const securityCmd = program
178
+ .command("security")
179
+ .description("Security level of this project: max (every catalog act signed by the phone) or normal (four layers, lesser guarantee)");
180
+ const SECURITY_ACTIONS = {
181
+ show: "The level in force, where it comes from and whether a phone is enrolled (exit 2 on a record that cannot be trusted)",
182
+ max: "Raise to max: no signature needed",
183
+ normal: "Lower to normal: a human act signed by the enrolled phone (refused without one)",
184
+ };
185
+ for (const [action, description] of Object.entries(SECURITY_ACTIONS)) {
186
+ securityCmd
187
+ .command(action, action === "show" ? { isDefault: true } : {})
188
+ .description(description)
189
+ .action(async (flags) => {
190
+ await withConfig(pkgVersion, `security-${action}`, flags, async ({ config }) => {
191
+ process.exitCode = await securityCommand({ action, config, kjVersion: pkgVersion, flags });
192
+ });
193
+ });
194
+ }
195
+
172
196
  // KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
173
197
  identityCmd
174
198
  .command("phone-invite")
@@ -412,7 +436,7 @@ export function registerMeta(program, { pkgVersion }) {
412
436
  .option("--file <path>", "The proposal", PROPOSAL_FILE)
413
437
  .action(async (flags) => {
414
438
  await withConfig(pkgVersion, "rules-approve", flags, async ({ config }) => {
415
- const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
439
+ const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file, kjVersion: pkgVersion });
416
440
  for (const line of res.lines) console.log(line);
417
441
  process.exitCode = res.code;
418
442
  });
@@ -8,7 +8,11 @@
8
8
  * no session and no flags → exit 1.
9
9
  */
10
10
 
11
- import { addSigner, enrollPhone } from "../harden/phone-sign.js";
11
+ import { createHash } from "node:crypto";
12
+
13
+ import { signAct } from "../harden/act-sign.js";
14
+ import { enrollPhone, isPhoneEnrolled, readEnrolledKey, readEnrolledKeys, validatePhonePublicKey } from "../harden/phone-sign.js";
15
+ import { admitSigner, redeemRecoveryCode } from "../harden/roster.js";
12
16
  import { phoneInvite } from "../harden/phone-invite.js";
13
17
  import { humanAct } from "../harden/human-act.js";
14
18
  import { readIdentity, writeIdentity } from "../identity/store.js";
@@ -16,7 +20,7 @@ import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
16
20
  import { compareIdentity } from "../identity/compare.js";
17
21
  import { createWizard, isTTY } from "../utils/wizard.js";
18
22
 
19
- export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
23
+ export async function identityCommand({ action = "show", config, kjVersion = "unknown", flags = {}, deps = {} }) {
20
24
  const projectDir = config?.projectDir || process.cwd();
21
25
  const log = deps.log || console.log;
22
26
  const tty = deps.isTTY || isTTY;
@@ -28,19 +32,43 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
28
32
 
29
33
  // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
30
34
  if (action === "enroll-phone") {
35
+ let recoveryCode;
31
36
  try {
32
37
  // KJC-TSK-0966 (HUM-C, ADR 0018): enrolar es un acto del catálogo; hasta hoy
33
38
  // solo lo frenaba el Sentinel, y un kj fuera de Claude no tiene Sentinel.
34
39
  humanAct("phone-enroll", { env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, ancestry: deps.ancestry ?? {}, confirm: deps.confirm });
35
- // KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
36
- // base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
37
- addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
38
- enrollPhone(flags.publicKeyBase64, { home: deps.home });
40
+ // KJC-TSK-0998 (HUM-D, ADR 0018): a phone of the roster signs the key that
41
+ // enters; the FIRST one has nobody to sign it (bootstrap) and runs on the four layers.
42
+ const key = validatePhonePublicKey(flags.publicKeyBase64);
43
+ const signers = ".karajan/supervisor-signers.json";
44
+ // KJC-TSK-0999 (HUM-B2, ADR 0018): a lost phone is replaced with the recovery
45
+ // code, never by reinstalling: its proof is the admission and the lost phone
46
+ // is not asked to sign. The code is checked BEFORE anything else happens.
47
+ const recovered = flags.recovery ? redeemRecoveryCode(flags.recovery, { projectDir }) : null;
48
+ // The lost phone is REVOKED by the same code: the one named with --replaces, or the one this machine had enrolled.
49
+ const lost = recovered ? (flags.replaces ?? (isPhoneEnrolled({ home: deps.home }) ? readEnrolledKey({ home: deps.home }) : null)) : null;
50
+ if (recovered && !lost) throw new Error("di qué móvil se pierde: --replaces <su clave pública> (esta máquina no tiene ninguno enrolado)");
51
+ if (recovered) log(`recuperación con el código: el móvil perdido (${lost.slice(0, 8)}…) queda revocado y no firma; el código gastado es la admisión del nuevo`);
52
+ const { signature } = await signAct("phone-enroll", {
53
+ projectDir, home: deps.home, kjVersion, logger: { warn: log, info: log },
54
+ deps: recovered ? { ...deps, phone: { enrolled: () => false } } : deps,
55
+ bootstrap: recovered !== null || readEnrolledKeys({ projectDir, home: deps.home }).size === 0,
56
+ files: [{ file: signers, sha256: createHash("sha256").update(key).digest("hex") }],
57
+ changes: [{ file: signers, status: "modified", added: 1, removed: 0, summary: `Entra la clave ${flags.label ?? "(sin etiqueta)"}`, diff: `+ ${key}`, truncated: false }],
58
+ });
59
+ // KJC-TSK-0831 (ADR 0010): the versioned roster, now with the admission CI
60
+ // verifies (HUM-B); the legacy key in ~/.karajan stays.
61
+ recoveryCode = admitSigner({ projectDir, publicKey: key, label: flags.label ?? null, admission: recovered ?? signature, replaces: lost }).recoveryCode;
62
+ enrollPhone(key, { home: deps.home });
39
63
  } catch (err) {
40
64
  log(`kj identity enroll-phone: ${err.message}`);
41
65
  return 1;
42
66
  }
43
67
  log("Móvil enrolado: la clave pública se añadió al padrón .karajan/supervisor-signers.json (commítealo) y a ~/.karajan/supervisor-phone.json. Sellar el supervisor pedirá la firma de un móvil del padrón.");
68
+ if (recoveryCode) {
69
+ log(`\nCÓDIGO DE RECUPERACIÓN, se enseña UNA sola vez. Guárdalo fuera de esta máquina:\n\n ${recoveryCode}\n`);
70
+ log("Sirve para enrolar un móvil nuevo si pierdes este: kj identity enroll-phone <clave nueva> --recovery <código>. Es de un solo uso: al usarlo se emite otro. Sin móvil y sin código no hay recuperación: el padrón se rehace por fuera de CI, a la vista de todos (ADR 0018).");
71
+ }
44
72
  return 0;
45
73
  }
46
74
 
@@ -25,6 +25,7 @@ import { detectQmd } from "../utils/qmd-detect.js";
25
25
  import { installQmd } from "../utils/qmd-install.js";
26
26
  import { registerQmdCollections } from "../utils/qmd-collection.js";
27
27
  import { isGitRepo } from "../harden/harden-engine.js";
28
+ import { isPhoneEnrolled } from "../harden/phone-sign.js";
28
29
  import { hardenCommand } from "./harden.js";
29
30
  import { contractChanges } from "../environment/contract-commit.js";
30
31
  import { detectProjectStack } from "../utils/stack-detect.js";
@@ -281,6 +282,27 @@ async function askBoardSecurity(wizard, config, logger) {
281
282
  logger.info(` -> HU Board port: ${config.hu_board.port}`);
282
283
  }
283
284
 
285
+ /**
286
+ * KJC-TSK-0997 (HUM-D4, ADR 0018): the level is max by default, so the dangerous
287
+ * acts are signed from the phone and refused without one. Enrolling is a human
288
+ * act of the catalog, never done from a wizard: init says what the phone
289
+ * protects, how to enroll it and how one goes on without it.
290
+ */
291
+ export function tellPhoneStep(logger, { enrolled = isPhoneEnrolled() } = {}) {
292
+ logger.info("Security: the phone");
293
+ if (enrolled) {
294
+ logger.info(" A phone is enrolled on this machine. The level is max by default: sealing the supervisor, approving rules, enrolling");
295
+ logger.info(" another phone and lowering the level are signed from it. `kj security show` tells the level of this project.");
296
+ return;
297
+ }
298
+ logger.info(" No phone is enrolled. The level is max by default: sealing the supervisor (kj harden --commit), approving rules");
299
+ logger.info(" and enrolling another phone are signed from your phone over the exact bytes, and refused until one is enrolled.");
300
+ logger.info(" 1. Open https://karajancode.com/sign on the phone: it creates a key pair and shows the PUBLIC key (the private one never leaves it).");
301
+ logger.info(" 2. From YOUR terminal, never from an agent session: kj identity enroll-phone <public key> --label <name>");
302
+ logger.info(" 3. Keep the recovery code it prints, off this machine: it replaces a lost phone (--recovery <code>).");
303
+ logger.info(" Prefer the four layers alone? kj security normal lowers the level, signed from the phone too (ADR 0018).");
304
+ }
305
+
284
306
  /**
285
307
  * Explicit, opt-in telemetry consent. We do NOT inherit a "Karajan-installed
286
308
  * users want to help" assumption — silence and missing config keys both mean
@@ -395,6 +417,8 @@ async function runWizard(config, logger) {
395
417
  await askTelemetry(wizard, config, logger);
396
418
 
397
419
  logger.info("");
420
+ tellPhoneStep(logger);
421
+ logger.info("");
398
422
  } finally {
399
423
  wizard.close();
400
424
  }
@@ -13,6 +13,7 @@ import { readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
15
15
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
16
+ import { rosterViolation } from "../policy/roster-check.js";
16
17
  import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
17
18
  import { buildPolicyReport } from "../policy/report.js";
18
19
  import { budgetedNet } from "../review/loc-budget.js";
@@ -23,10 +24,11 @@ import { verifyDecisionChain } from "@karajan-family/governance";
23
24
 
24
25
  const execFileAsync = promisify(execFile);
25
26
 
27
+ const gitRunner = (projectDir, gitFn) =>
28
+ gitFn || (async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
29
+
26
30
  async function stagedFacts(projectDir, gitFn, range = null) {
27
- const run =
28
- gitFn ||
29
- (async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
31
+ const run = gitRunner(projectDir, gitFn);
30
32
  // PL-C (KJC-TSK-0735): en CI no hay staged — con --range se evalúa
31
33
  // base...head, el MISMO motor sobre el diff del PR (tier C del ADR 0001).
32
34
  const base = range ? ["diff", range] : ["diff", "--cached"];
@@ -247,6 +249,10 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
247
249
  if (sup.note) logger.info?.(`ℓ policy: ${sup.note}`);
248
250
  violations = sup.violations;
249
251
  }
252
+ // KJC-TSK-1000 (HUM-B3, ADR 0018): a change of the roster of phones is judged
253
+ // against the base's roster: admitted by a present phone or by the recovery code.
254
+ const roster = await rosterViolation({ projectDir, files: facts.files, range: flags.range || null, run: gitRunner(projectDir, deps.gitFn) });
255
+ if (roster) violations = [...violations, roster];
250
256
  const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
251
257
  if (flags.json) {
252
258
  logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
@@ -7,11 +7,13 @@
7
7
  * The proposal is read ONCE: what is checked, what is shown and what is
8
8
  * installed are the same rules, whatever happens to the file meanwhile.
9
9
  */
10
+ import { createHash } from "node:crypto";
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
12
13
 
13
14
  import yaml from "js-yaml";
14
15
 
16
+ import { signAct } from "../harden/act-sign.js";
15
17
  import { humanActOf } from "../harden/human-act.js";
16
18
  import { checkVerdict } from "../review/verdict-store.js";
17
19
  import { approvalView } from "../rules/approval-view.js";
@@ -23,7 +25,10 @@ import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } fr
23
25
  * deps?: {confirm?: Function, ancestry?: object}, log?: (line: string) => void}} opts
24
26
  * @returns {Promise<{code: 0|1, lines: string[]}>}
25
27
  */
26
- export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
28
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
29
+ const lines = (text) => (text ? text.split("\n").length - 1 : 0);
30
+
31
+ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, kjVersion = "unknown", env, tty, deps = {}, log = console.log }) {
27
32
  const act = humanActOf("rules-approve");
28
33
  act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
29
34
  const proposal = path.resolve(projectDir, file);
@@ -52,9 +57,21 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
52
57
  // KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
53
58
  // is not versioned. Where it goes is the inventory's word, not the proposal's.
54
59
  const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
55
- for (const [name, part] of parts) {
60
+ const dumps = parts.map(([name, part]) => [name, part.length ? yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }) : null]);
61
+ // KJC-TSK-0998 (HUM-D, ADR 0018): the phone signs the exact YAML that lands, and
62
+ // sees the rules as the approval view showed them. At max with no phone: no approval.
63
+ const before = (name) => { try { return fs.readFileSync(path.join(projectDir, name), "utf8"); } catch { return null; } };
64
+ await signAct("rules-approve", {
65
+ projectDir, home, kjVersion, deps, logger: { warn: log, info: log },
66
+ files: dumps.map(([name, text]) => ({ file: name, sha256: text ? sha256(text) : "" })),
67
+ changes: dumps.map(([name, text]) => ({
68
+ file: name, status: text ? (before(name) ? "modified" : "new") : "deleted", added: lines(text), removed: lines(before(name)),
69
+ summary: `${parts.find(([n]) => n === name)[1].length} rule(s)`, diff: view.join("\n"), truncated: false,
70
+ })),
71
+ });
72
+ for (const [name, text] of dumps) {
56
73
  const target = path.join(projectDir, name);
57
- if (part.length) fs.writeFileSync(target, yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }));
74
+ if (text) fs.writeFileSync(target, text);
58
75
  else fs.rmSync(target, { force: true }); // no rule left for this file: none stays behind
59
76
  }
60
77
  fs.rmSync(proposal, { force: true });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * kj security show|normal|max (KJC-TSK-0995, HUM-D, ADR 0018): the project's
3
+ * security level. `show` says the level in force and where it comes from;
4
+ * `max` is free; `normal` is the catalog act security-level-down, signed by
5
+ * the phone (src/harden/security-level.js).
6
+ */
7
+ import { isPhoneEnrolled } from "../harden/phone-sign.js";
8
+ import { lowerSecurityLevel, raiseSecurityLevel, readSecurityLevel, SECURITY_LEVEL_FILE } from "../harden/security-level.js";
9
+
10
+ const MEANING = {
11
+ max: "máxima: todo acto del catálogo (sello, reglas, enrolar un móvil) se firma con el móvil",
12
+ normal: "normal: las cuatro capas de ADR 0009 y un código tecleado; la garantía es menor",
13
+ };
14
+ const sourceLine = (level) => {
15
+ if (level.source === "default") return "por defecto (sin registro)";
16
+ if (level.source === "invalid") return `REGISTRO NO FIABLE: ${level.reason}`;
17
+ return `registrado en ${SECURITY_LEVEL_FILE} desde ${level.since ?? "?"}${level.signer ? ", firmado por un móvil del padrón" : ""}`;
18
+ };
19
+
20
+ /** @returns {Promise<0|1|2>} 2 when the record cannot be trusted */
21
+ export async function securityCommand({ action = "show", config, kjVersion, flags = {}, deps = {}, log = console.log }) {
22
+ const projectDir = config?.projectDir || process.cwd();
23
+ const home = deps.home;
24
+ if (action === "max") {
25
+ const res = raiseSecurityLevel({ projectDir, home });
26
+ log(res.changed ? `kj security: nivel máxima registrado en ${SECURITY_LEVEL_FILE} (commítealo: viaja con el repo)` : "kj security: el nivel ya es máxima");
27
+ return 0;
28
+ }
29
+ if (action === "normal") {
30
+ try {
31
+ const res = await lowerSecurityLevel({ projectDir, home, kjVersion, env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, deps, logger: deps.logger ?? console });
32
+ log(res.changed ? `kj security: nivel normal firmado y registrado en ${SECURITY_LEVEL_FILE} (commítealo). Desde ahora kj avisará en cada acto de que la garantía es menor; kj security max lo sube.` : "kj security: el nivel ya es normal");
33
+ return 0;
34
+ } catch (err) {
35
+ log(`kj security normal: ${err.message}`);
36
+ return 1;
37
+ }
38
+ }
39
+ const level = readSecurityLevel({ projectDir, home });
40
+ log(`nivel ${MEANING[level.level]}`);
41
+ log(`origen ${sourceLine(level)}`);
42
+ log(`móvil ${isPhoneEnrolled({ home }) ? "enrolado" : "no enrolado (kj identity enroll-phone <clave pública de la app>)"}`);
43
+ if (flags.json) log(JSON.stringify(level));
44
+ return level.source === "invalid" ? 2 : 0;
45
+ }
@@ -30,6 +30,8 @@ export const CONTRACT_PATHS = [
30
30
  ".karajan/rules.yml",
31
31
  ".karajan/policy-anchor.json",
32
32
  ".karajan/supervisor-signers.json",
33
+ // KJC-TSK-0995 (HUM-D, ADR 0018): the security level the whole team inherits.
34
+ ".karajan/security-level.json",
33
35
  ];
34
36
 
35
37
  const FRESH_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * KJC-TSK-0996 (HUM-D, ADR 0018): the security level applied to every act of
3
+ * the catalog, in one place. With a phone enrolled the act is signed by it over
4
+ * the exact bytes of what it does, and the request says what is done, why the
5
+ * signature is asked and what would happen if another did it. Without a phone:
6
+ * at `max` the act is refused and told how to enroll (never a typed code
7
+ * instead), except the first enrollment, which nobody could sign; at `normal`
8
+ * the four layers stand and the act says the guarantee is lesser.
9
+ */
10
+ import { basename } from "node:path";
11
+
12
+ import { humanActOf } from "./human-act.js";
13
+ import { PHONE_INVITE } from "./phone-invite.js";
14
+ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
15
+ import { readSecurityLevel } from "./security-level.js";
16
+
17
+ const EXPLAIN = {
18
+ "supervisor-seal": [
19
+ "Se sella el supervisor regenerado por kj harden: los hooks y guardias listados quedan como los bytes que firmas.",
20
+ "Sin tu firma el CI no acepta la procedencia: nadie, ni el agente, da por bueno un supervisor que tú no viste.",
21
+ "Quien altere un hook sin este sello deja la procedencia incompleta y CI lo rechaza.",
22
+ ],
23
+ "rules-approve": [
24
+ "Se instalan las reglas compiladas de tus MD como gates del proyecto (.karajan/rules.yml).",
25
+ "El agente propone cómo se compilan las reglas que le vigilan; solo tú las apruebas.",
26
+ "Reglas instaladas sin tu firma no son tuyas: el agente podría aflojar las que le frenan.",
27
+ ],
28
+ "phone-enroll": [
29
+ "Entra una clave pública nueva en el padrón de móviles que pueden firmar en este proyecto.",
30
+ "Quien está en el padrón sella, aprueba reglas y baja el nivel: solo un móvil ya enrolado admite a otro.",
31
+ "Una clave añadida sin la firma de un móvil del padrón daría a otro tu poder de firma.",
32
+ ],
33
+ };
34
+
35
+ /** What the phone shows beside the files: what, why, and if another did it. */
36
+ export function explainAct(id) {
37
+ const [what, why, other] = EXPLAIN[id] ?? [];
38
+ if (!what) throw new Error(`"${id}" has no explanation for the phone (src/harden/act-sign.js, ADR 0018)`);
39
+ return `Qué se va a hacer: ${what}\nPor qué se pide tu firma: ${why}\nSi lo hiciera otro: ${other}`;
40
+ }
41
+
42
+ /**
43
+ * @param {string} id catalog id (src/harden/human-act.js)
44
+ * @param {{projectDir: string, home?: string, kjVersion: string, files: {file: string, sha256: string}[],
45
+ * changes?: object[], branch?: string|null, bootstrap?: boolean, logger?: object, deps?: {phone?: object}}} opts
46
+ * `bootstrap`: the act may run with no phone enrolled even at max (the first enrollment).
47
+ * @returns {Promise<{level: object, signature: object|null}>} the block to record, or null when no phone signed
48
+ */
49
+ export async function signAct(id, { projectDir, home, kjVersion, files, changes = [], branch = null, bootstrap = false, logger = console, deps = {} }) {
50
+ const act = humanActOf(id);
51
+ const level = readSecurityLevel({ projectDir, home });
52
+ if (level.source === "invalid") logger.warn?.(`${act.label}: ${level.reason}`);
53
+ const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
54
+ if (!phone.enrolled({ home })) {
55
+ if (level.level === "max" && !bootstrap) {
56
+ throw new Error(`${act.label}: con seguridad máxima este acto se firma con el móvil y no hay ninguno enrolado. ${PHONE_INVITE}`);
57
+ }
58
+ logger.warn?.(`${act.label}: sin móvil enrolado solo valen las cuatro capas y la garantía es menor (ADR 0018). ${PHONE_INVITE}`);
59
+ return { level, signature: null };
60
+ }
61
+ if (level.level === "normal") logger.warn?.(`${act.label}: seguridad normal, la garantía es menor que con la máxima; kj security max la sube`);
62
+ const signed = await phone.request({
63
+ project: basename(projectDir), files, kjVersion, changes, branch, why: explainAct(id),
64
+ origin: `${act.label} (kj ${kjVersion})`, logger, deps: { projectDir, home },
65
+ });
66
+ if (!signed.ok) throw new Error(`${act.label}: firma del móvil rechazada (${signed.reason}); con móvil enrolado el acto exige su firma (ADR 0018)`);
67
+ const signature = signed.challenge && signed.signer && signed.signature ? { ...signed.challenge, signer: signed.signer, signature: signed.signature } : null;
68
+ return { level, signature };
69
+ }
@@ -82,7 +82,7 @@ export const HUMAN_ACTS = Object.freeze({
82
82
  "rules-approve": { label: "kj rules approve", module: "src/commands/rules-approve.js" },
83
83
  "phone-enroll": { label: "kj identity enroll-phone", module: "src/commands/identity.js" },
84
84
  "handoff-run": { label: "kj handoff run", module: null, pending: "KJC-TSK-0968" },
85
- "security-level-down": { label: "kj security normal", module: null, pending: "KJC-TSK-0967" },
85
+ "security-level-down": { label: "kj security normal", module: "src/harden/security-level.js" },
86
86
  });
87
87
 
88
88
  /**
@@ -74,7 +74,8 @@ export const readEnrolledKey = ({ home } = {}) =>
74
74
  export function readSigners({ projectDir } = {}) {
75
75
  try {
76
76
  const doc = JSON.parse(readFileSync(signersPath(projectDir), "utf8"));
77
- return Array.isArray(doc?.signers) ? doc.signers.map((s) => s.publicKey).filter(Boolean) : [];
77
+ // KJC-TSK-0999 (HUM-B2): a phone replaced with the recovery code is revoked and signs no more.
78
+ return Array.isArray(doc?.signers) ? doc.signers.filter((s) => !s?.revoked).map((s) => s.publicKey).filter(Boolean) : [];
78
79
  } catch {
79
80
  return [];
80
81
  }
@@ -142,20 +143,29 @@ export function verifyPhoneSignature({ payload, signature, publicKey }) {
142
143
  export function provenanceSignature({ projectDir, provenance }) {
143
144
  const roster = readSigners({ projectDir });
144
145
  if (roster.length === 0) return { required: false, ok: true };
145
- const fail = (reason) => ({ required: true, ok: false, reason });
146
- const sig = provenance?.signature;
147
- if (typeof sig?.signature !== "string" || typeof sig?.signer !== "string") return fail("el padrón tiene firmantes y la procedencia no lleva firma");
148
- if (!roster.includes(sig.signer)) return fail("la clave que firma la procedencia no está en el padrón");
149
146
  // The files as the phone signed them: {file, sha256}, a deletion with no hash.
150
147
  // A provenance is read from the project: a malformed one is rejected, it does not crash.
151
- const entries = Array.isArray(provenance.files) ? provenance.files : [];
152
- if (entries.some((entry) => typeof entry?.file !== "string")) return fail("la procedencia declara ficheros mal formados");
148
+ const entries = Array.isArray(provenance?.files) ? provenance.files : [];
149
+ if (entries.some((entry) => typeof entry?.file !== "string")) return { required: true, ok: false, reason: "la procedencia declara ficheros mal formados" };
153
150
  const files = entries.map(({ file, sha256 }) => ({ file, sha256: sha256 ?? "" }));
151
+ const check = verifySignedFiles({ sig: provenance?.signature, files, keys: new Set(roster) });
152
+ return check.ok ? { required: true, ok: true } : { required: true, ok: false, reason: `el padrón tiene firmantes y la procedencia ${check.reason}` };
153
+ }
154
+
155
+ /**
156
+ * KJC-TSK-0967 (HUM-D): a signature block (challenge + signer + signature, as
157
+ * the seal and the security level record it) over `files`, against `keys`.
158
+ * The reason reads after a subject: "la procedencia no lleva firma".
159
+ * @returns {{ok: boolean, reason?: string}}
160
+ */
161
+ export function verifySignedFiles({ sig, files, keys }) {
162
+ if (typeof sig?.signature !== "string" || typeof sig?.signer !== "string") return { ok: false, reason: "no lleva firma" };
163
+ if (!keys.has(sig.signer)) return { ok: false, reason: "está firmada por una clave que no está en el padrón" };
154
164
  let good = false;
155
165
  try {
156
166
  good = verifyPhoneSignature({ payload: canonicalPayload({ ...sig, files }), signature: sig.signature, publicKey: sig.signer });
157
167
  } catch { /* a malformed key or signature is a signature that does not verify */ }
158
- return good ? { required: true, ok: true } : fail("la firma no corresponde a los ficheros que la procedencia declara");
168
+ return good ? { ok: true } : { ok: false, reason: "lleva una firma que no corresponde a los ficheros que declara" };
159
169
  }
160
170
 
161
171
  /**
@@ -171,7 +181,7 @@ const changesValue = (changes) => ({ arrayValue: { values: changes.map((c) => ({
171
181
  summary: str(c.summary), diff: str(c.diff), truncated: { booleanValue: Boolean(c.truncated) },
172
182
  } } })) } });
173
183
 
174
- export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, logger = console, deps = {} }) {
184
+ export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, why = null, logger = console, deps = {} }) {
175
185
  const fetchFn = deps.fetch ?? fetch;
176
186
  const now = deps.now ?? Date.now;
177
187
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -195,6 +205,8 @@ export async function requestPhoneSignature({ project, files, kjVersion, changes
195
205
  ...(Array.isArray(changes) ? { changes: changesValue(changes) } : {}),
196
206
  ...(branch ? { branch: str(branch) } : {}),
197
207
  ...(origin ? { origin: str(origin) } : {}),
208
+ // KJC-TSK-0996 (HUM-D): what is done, why the signature is asked, what if another did it.
209
+ ...(why ? { why: str(why) } : {}),
198
210
  },
199
211
  };
200
212
  const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * KJC-TSK-0965 (HUM-B, ADR 0018): the roster of signing phones and its recovery
3
+ * code. A phone enters with an ADMISSION: a present phone's signature over the
4
+ * sha256 of its key, or the recovery code, spent. The code is shown once and NEVER
5
+ * stored: the roster keeps sha256(sha256(code)), and spending it reveals sha256(code),
6
+ * which only the holder can produce. CI judges a change with `verifyRosterChange`.
7
+ */
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import { validatePhonePublicKey, verifySignedFiles } from "./phone-sign.js";
13
+
14
+ export const SIGNERS_FILE = ".karajan/supervisor-signers.json";
15
+ const CODE_RE = /^kjrc(-[a-z2-7]{5}){4}$/;
16
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
17
+ const short = (key) => `${String(key).slice(0, 8)}…`;
18
+ const validKey = (key) => { try { validatePhonePublicKey(key); return true; } catch { return false; } };
19
+
20
+ /** 100 random bits as kjrc-xxxxx-xxxxx-xxxxx-xxxxx; the roster keeps the fingerprint only. */
21
+ export function issueRecoveryCode(random = randomBytes) {
22
+ const chars = [...random(20)].map((b) => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]);
23
+ const code = `kjrc-${[0, 5, 10, 15].map((i) => chars.slice(i, i + 5).join("")).join("-")}`;
24
+ return { code, fingerprint: sha256(sha256(code)) };
25
+ }
26
+
27
+ /** What a present phone signs to admit a key: the roster file and the key's sha256. */
28
+ export const admissionFiles = (publicKey) => [{ file: SIGNERS_FILE, sha256: sha256(publicKey) }];
29
+
30
+ const normalize = (doc) => ({ signers: Array.isArray(doc?.signers) ? doc.signers.filter((s) => typeof s?.publicKey === "string") : [], recovery: doc?.recovery ?? null });
31
+
32
+ export function readRoster({ projectDir }) {
33
+ try { return normalize(JSON.parse(readFileSync(join(projectDir, SIGNERS_FILE), "utf8"))); } catch { return normalize(null); }
34
+ }
35
+
36
+ /** The code must match the roster's fingerprint; returns the admission (its proof, not the code). Writes nothing. */
37
+ export function redeemRecoveryCode(code, { projectDir }) {
38
+ if (!CODE_RE.test(code ?? "")) throw new Error("el código de recuperación no tiene el formato kjrc-xxxxx-xxxxx-xxxxx-xxxxx");
39
+ const { recovery } = readRoster({ projectDir });
40
+ if (!recovery?.fingerprint) throw new Error("este padrón no tiene código de recuperación: se emite al enrolar el primer móvil");
41
+ const proof = sha256(code);
42
+ if (sha256(proof) !== recovery.fingerprint) throw new Error("el código de recuperación no es el de este padrón");
43
+ return { recoveryProof: proof };
44
+ }
45
+
46
+ /**
47
+ * Adds a signer with its admission and writes the roster. The first signer, a spent
48
+ * code and a roster with no code yet issue a NEW code, returned once and never stored.
49
+ */
50
+ export function admitSigner({ projectDir, publicKey, label = null, admission = null, replaces = null, random }) {
51
+ validatePhonePublicKey(publicKey);
52
+ const roster = readRoster({ projectDir });
53
+ if (roster.signers.some((s) => s.publicKey === publicKey)) return { roster, recoveryCode: null };
54
+ const first = roster.signers.length === 0;
55
+ if (!first && !admission) throw new Error("un móvil nuevo entra firmado por uno del padrón o con el código de recuperación (ADR 0018)");
56
+ const enrolledAt = new Date().toISOString();
57
+ // KJC-TSK-0999 (HUM-B2): the lost phone the code replaces is REVOKED by the same
58
+ // spent code; its entry stays, marked, so CI can see the revocation is backed.
59
+ if (replaces && !admission?.recoveryProof) throw new Error("sustituir un móvil se hace con el código de recuperación");
60
+ if (replaces && !roster.signers.some((s) => s.publicKey === replaces && !s.revoked)) throw new Error(`el móvil a sustituir (${short(replaces)}) no está en el padrón`);
61
+ const kept = roster.signers.map((s) => (s.publicKey === replaces ? { ...s, revoked: { at: enrolledAt, recoveryProof: admission.recoveryProof } } : s));
62
+ const next = { signers: [...kept, { publicKey, label, enrolledAt, ...(admission ? { admission } : {}) }], recovery: roster.recovery };
63
+ let recoveryCode = null;
64
+ if (first || admission?.recoveryProof || !roster.recovery) {
65
+ const issued = issueRecoveryCode(random);
66
+ recoveryCode = issued.code;
67
+ next.recovery = { fingerprint: issued.fingerprint, issuedAt: enrolledAt };
68
+ }
69
+ const path = join(projectDir, SIGNERS_FILE);
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
72
+ return { roster: next, recoveryCode };
73
+ }
74
+
75
+ /**
76
+ * CI's judgment of a roster change: every key that enters carries a valid admission,
77
+ * no key leaves, and the code's sha256 rotates only when one is spent (once).
78
+ * @returns {{ok: boolean, reason?: string, bootstrap?: boolean, added?: number, spent?: boolean}}
79
+ */
80
+ export function verifyRosterChange({ before, after }) {
81
+ const fail = (reason) => ({ ok: false, reason });
82
+ const prev = normalize(before);
83
+ const next = normalize(after);
84
+ // `known`: every key the previous roster had, revoked or not (a revoked one is not new);
85
+ // `present`: the ones still allowed to sign, the only ones that can admit a key.
86
+ const known = new Set(prev.signers.map((s) => s.publicKey));
87
+ const present = new Set(prev.signers.filter((s) => !s.revoked).map((s) => s.publicKey));
88
+ const fresh = /^[0-9a-f]{64}$/.test(next.recovery?.fingerprint ?? "") ? next.recovery.fingerprint : null;
89
+ const gone = prev.signers.find((s) => !next.signers.some((n) => n.publicKey === s.publicKey));
90
+ if (gone) return fail(`el padrón pierde la clave ${short(gone.publicKey)}: una baja no tiene mecanismo en kj y solo se hace por fuera de CI, a la vista`);
91
+ const back = prev.signers.find((s) => s.revoked && !next.signers.find((n) => n.publicKey === s.publicKey)?.revoked);
92
+ if (back) return fail(`la clave ${short(back.publicKey)} estaba revocada y una revocación no se deshace`);
93
+ // KJC-TSK-0999: a key is revoked only by the recovery code spent in this same change.
94
+ const backed = (proof) => sha256(String(proof ?? "")) === prev.recovery?.fingerprint;
95
+ const revoked = next.signers.filter((n) => n.revoked && !prev.signers.find((s) => s.publicKey === n.publicKey)?.revoked);
96
+ if (revoked.length > 1) return fail("un código de recuperación revoca UN móvil, y este cambio revoca varios");
97
+ if (revoked.length === 1 && !backed(revoked[0].revoked.recoveryProof)) return fail(`la clave ${short(revoked[0].publicKey)} se revoca sin el código de recuperación de este padrón`);
98
+ if (known.size === 0) return next.signers.length === 1 && fresh && validKey(next.signers[0].publicKey) ? { ok: true, bootstrap: true } : fail("el primer padrón lleva UN móvil (clave ed25519 válida) y la huella de su código de recuperación; los demás entran admitidos");
99
+ const added = next.signers.filter((n) => !known.has(n.publicKey));
100
+ let spent = false;
101
+ for (const entry of added) {
102
+ if (!validKey(entry.publicKey)) return fail(`la clave ${short(entry.publicKey)} no es una clave ed25519 válida (base64 de 32 bytes)`);
103
+ const admission = entry.admission ?? null;
104
+ if (admission?.recoveryProof) {
105
+ if (spent) return fail("un código de recuperación admite UNA clave, y este cambio mete dos con él");
106
+ if (!backed(admission.recoveryProof)) return fail(`la clave ${short(entry.publicKey)} entra con un código de recuperación que no es el del padrón anterior`);
107
+ spent = true;
108
+ continue;
109
+ }
110
+ const check = verifySignedFiles({ sig: admission, files: admissionFiles(entry.publicKey), keys: present });
111
+ if (!check.ok) return fail(`la clave ${short(entry.publicKey)} entra sin admisión válida: ${check.reason}`);
112
+ }
113
+ if (spent && (!fresh || fresh === prev.recovery?.fingerprint)) return fail("el código de recuperación se gastó y el padrón no trae la huella de uno nuevo");
114
+ if (!spent && (prev.recovery ? fresh !== prev.recovery.fingerprint : added.length > 0 && !fresh)) return fail("la huella del código de recuperación cambia sin que se haya gastado ninguno, o falta cuando entra un móvil en un padrón sin código");
115
+ return { ok: true, added: added.length, spent };
116
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * KJC-TSK-0967 (HUM-D, ADR 0018): the project's security level, versioned in
3
+ * .karajan/security-level.json: `max` (every catalog act signed by the phone
4
+ * over the exact bytes) or `normal` (the four layers of ADR 0009, a lesser
5
+ * guarantee said at every act). Going DOWN is itself a catalog act signed by an
6
+ * enrolled phone over the record's bytes; a `normal` record without that
7
+ * signature reads as `max`. Going up is free.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+
14
+ import { humanActOf } from "./human-act.js";
15
+ import { PHONE_INVITE } from "./phone-invite.js";
16
+ import { isPhoneEnrolled, readEnrolledKeys, requestPhoneSignature, verifySignedFiles } from "./phone-sign.js";
17
+
18
+ export const SECURITY_LEVEL_FILE = ".karajan/security-level.json";
19
+ // ADR 0018 (KJC-TSK-0997): `max` is the default now that CI verifies the signature
20
+ // (HUM-A) and the recovery code exists (HUM-B). The level that protects whoever
21
+ // does not know the risk exists; `kj security normal` lowers it, signed.
22
+ export const DEFAULT_LEVEL = "max";
23
+
24
+ const levelPath = (projectDir) => join(projectDir, SECURITY_LEVEL_FILE);
25
+ const recordBytes = (record) => `${JSON.stringify(record, null, 2)}\n`;
26
+ const unsigned = ({ level, since }) => ({ level, since });
27
+
28
+ /** What the phone signs when the level goes down: the record without its signature. */
29
+ export const signedFilesOf = (record) => [
30
+ { file: SECURITY_LEVEL_FILE, sha256: createHash("sha256").update(recordBytes(unsigned(record))).digest("hex") },
31
+ ];
32
+
33
+ /**
34
+ * The level in force. A record that cannot be trusted reads as `max` and says why.
35
+ * @returns {{level: "max"|"normal", source: "default"|"file"|"invalid", since?: string, signer?: string, reason?: string}}
36
+ */
37
+ export function readSecurityLevel({ projectDir, home } = {}) {
38
+ const invalid = (reason) => ({ level: "max", source: "invalid", reason: `${SECURITY_LEVEL_FILE}: ${reason}` });
39
+ const path = levelPath(projectDir);
40
+ if (!existsSync(path)) return { level: DEFAULT_LEVEL, source: "default" };
41
+ let record;
42
+ try { record = JSON.parse(readFileSync(path, "utf8")); } catch (err) { return invalid(`no es JSON (${err.message})`); }
43
+ if (record?.level === "max") return { level: "max", source: "file", since: record.since };
44
+ if (record?.level !== "normal") return invalid("declara un nivel que no existe");
45
+ const keys = readEnrolledKeys({ projectDir, home });
46
+ const check = verifySignedFiles({ sig: record.signature, files: signedFilesOf(record), keys });
47
+ if (!check.ok) return invalid(`el nivel normal ${check.reason}; vale como máxima`);
48
+ return { level: "normal", source: "file", since: record.since, signer: record.signature.signer };
49
+ }
50
+
51
+ /** Up to `max`: no act, no signature; it also replaces a record that cannot be trusted. */
52
+ export function raiseSecurityLevel({ projectDir, home } = {}) {
53
+ const current = readSecurityLevel({ projectDir, home });
54
+ if (current.level === "max" && current.source !== "invalid") return { changed: false, level: current };
55
+ mkdirSync(dirname(levelPath(projectDir)), { recursive: true });
56
+ writeFileSync(levelPath(projectDir), recordBytes({ level: "max", since: new Date().toISOString() }), "utf8");
57
+ return { changed: true, level: readSecurityLevel({ projectDir, home }) };
58
+ }
59
+
60
+ const explain = (status) => ({
61
+ file: SECURITY_LEVEL_FILE, status, added: 3, removed: status === "modified" ? 3 : 0, truncated: false,
62
+ summary: "El nivel de seguridad de este proyecto baja de máxima a normal.",
63
+ diff: [
64
+ "Qué se va a hacer: los actos del catálogo (sellar el supervisor, aprobar reglas, enrolar un móvil) pasan a confirmarse",
65
+ "con las cuatro capas de ADR 0009 y un código tecleado; el móvil deja de firmarlos.",
66
+ "Por qué se pide tu firma: bajar el nivel es un acto del catálogo (ADR 0018); si no se firmara, el primero en bajarlo sería el agente.",
67
+ "Si lo hiciera otro: un registro de nivel normal sin la firma de un móvil del padrón no vale, y kj aplica la máxima.",
68
+ ].join("\n"),
69
+ });
70
+
71
+ /** Down to `normal`: the catalog act, four layers and the phone's signature; without a phone it refuses. */
72
+ export async function lowerSecurityLevel({ projectDir, home, kjVersion, env, tty, deps = {}, logger = console }) {
73
+ const act = humanActOf("security-level-down");
74
+ act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
75
+ const current = readSecurityLevel({ projectDir, home });
76
+ if (current.level === "normal") return { changed: false, level: current };
77
+ const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
78
+ if (!phone.enrolled({ home })) throw new Error(`${act.label}: bajar el nivel se firma con el móvil y no hay ninguno enrolado. ${PHONE_INVITE}`);
79
+ act.confirm(deps.confirm);
80
+ const record = { level: "normal", since: new Date().toISOString() };
81
+ let branch = null;
82
+ try { branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir, encoding: "utf8" }).trim(); } catch { /* no repo */ }
83
+ const signed = await phone.request({
84
+ project: basename(projectDir), files: signedFilesOf(record), kjVersion, branch, logger,
85
+ changes: [explain(existsSync(levelPath(projectDir)) ? "modified" : "new")],
86
+ origin: `kj security normal (kj ${kjVersion})`, deps: { projectDir, home },
87
+ });
88
+ if (!signed.ok) throw new Error(`${act.label}: firma del móvil rechazada (${signed.reason}); el nivel no cambia`);
89
+ if (!signed.challenge || !signed.signer || !signed.signature) throw new Error(`${act.label}: el relé no devolvió un desafío firmado que kj pueda grabar; el nivel no cambia`);
90
+ const signature = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
91
+ mkdirSync(dirname(levelPath(projectDir)), { recursive: true });
92
+ writeFileSync(levelPath(projectDir), recordBytes({ ...record, signature }), "utf8");
93
+ return { changed: true, level: readSecurityLevel({ projectDir, home }) };
94
+ }
@@ -8,13 +8,13 @@
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
10
  import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
- import { basename, join } from "node:path";
11
+ import { join } from "node:path";
12
12
 
13
13
  import { recordGateDecision } from "../policy/decisions.js";
14
14
  import { readIdentity } from "../identity/store.js";
15
15
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
16
+ import { signAct } from "./act-sign.js";
16
17
  import { humanActOf } from "./human-act.js";
17
- import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
18
18
  import { describeSupervisorChanges } from "./sign-changes.js";
19
19
 
20
20
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
@@ -120,33 +120,18 @@ export async function commitSupervisorRegeneration({
120
120
  logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
121
121
  return { committed: false, reason: "sin drift" };
122
122
  }
123
- // Capa 5 (KJC-TSK-0822 / PRP-0023): con móvil enrolado la firma asimétrica
124
- // es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
125
- // files/hashes de la provenance (normalizados al par {file, sha256}).
126
- const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
127
- let signatureBlock = null;
128
- if (phone.enrolled({})) {
129
- // KJC-TSK-0992 (HUM-G): the page says what is signed, file by file.
130
- let branch = null;
131
- try { branch = run(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); } catch { /* unborn HEAD */ }
132
- const signed = await phone.request({
133
- project: basename(projectDir),
134
- files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
135
- kjVersion,
136
- changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
137
- branch,
138
- origin: `kj harden ${kjVersion}`,
139
- logger,
140
- });
141
- if (!signed.ok) {
142
- throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
143
- }
144
- // KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
145
- // re-verifique server-side que un humano (clave del padrón) aprobó.
146
- if (signed.challenge && signed.signature) {
147
- signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
148
- }
149
- }
123
+ // Capa 5 (KJC-TSK-0822 / PRP-0023, KJC-TSK-0996 / ADR 0018): the level applied
124
+ // by the catalog's mechanism. With a phone enrolled its signature over the SAME
125
+ // files/hashes of the provenance is mandatory, never nonce-only; at max with no
126
+ // phone the seal is refused. KJC-TSK-0992: the page says what is signed, file by
127
+ // file; KJC-TSK-0823: the block is recorded so CI re-verifies server-side.
128
+ let branch = null;
129
+ try { branch = run(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); } catch { /* unborn HEAD */ }
130
+ const { signature: signatureBlock } = await signAct("supervisor-seal", {
131
+ projectDir, kjVersion, branch, logger, deps,
132
+ files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
133
+ changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
134
+ });
150
135
  const who = readIdentity(projectDir);
151
136
  const provenance = {
152
137
  kj_version: kjVersion,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * KJC-TSK-1000 (HUM-B3, ADR 0018): CI judges every change of the roster of
3
+ * signing phones. When the diff touches .karajan/supervisor-signers.json, the
4
+ * roster of the base (the range's start, or HEAD for a staged check) and the
5
+ * one that lands (the checkout, or the staged blob) go through
6
+ * `verifyRosterChange`; a refusal is a deny violation with its reason, so that
7
+ * reinstalling kj on a machine never changes what CI demands.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { SIGNERS_FILE, verifyRosterChange } from "../harden/roster.js";
13
+
14
+ export const ROSTER_RULE = "defaults.roster.change";
15
+
16
+ const parse = (text) => { try { return JSON.parse(text); } catch { return {}; } };
17
+ // An object that is not a roster reads as an empty one: a base that is missing is
18
+ // a first roster; a landing one that is missing or broken loses every key.
19
+ const shown = async (run, spec) => { try { return parse(await run(["show", spec])); } catch { return null; } };
20
+ const local = (projectDir) => { try { return parse(readFileSync(join(projectDir, SIGNERS_FILE), "utf8")); } catch { return null; } };
21
+
22
+ /**
23
+ * @param {{projectDir: string, files: string[], range: string|null, run: (args: string[]) => Promise<string>}} opts
24
+ * @returns {Promise<object|null>} the violation, or null when the roster is untouched or the change is backed
25
+ */
26
+ export async function rosterViolation({ projectDir, files, range, run }) {
27
+ if (!files.includes(SIGNERS_FILE)) return null;
28
+ const baseRef = range ? range.split(/\.{2,3}/)[0] : "HEAD";
29
+ const before = await shown(run, `${baseRef}:${SIGNERS_FILE}`);
30
+ const after = range ? local(projectDir) : await shown(run, `:${SIGNERS_FILE}`);
31
+ const verdict = verifyRosterChange({ before, after });
32
+ if (verdict.ok) return null;
33
+ return { rule_id: ROSTER_RULE, enforcement: "deny", file: SIGNERS_FILE, reason: `el padrón de móviles cambia sin respaldo (ADR 0018): ${verdict.reason}` };
34
+ }
@@ -42,6 +42,8 @@ export const CONTRACT_BLOCK = [
42
42
  // ADR 0016 (KJC-TSK-0947): the rules of the MD files compiled into gates are
43
43
  // governance the whole team inherits, like the hooks.
44
44
  "!.karajan/rules.yml",
45
+ // KJC-TSK-0995 (HUM-D, ADR 0018): the security level, signed when lowered, is the team's.
46
+ "!.karajan/security-level.json",
45
47
  ];
46
48
 
47
49
  /**