karajan-code 4.45.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.45.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,14 +8,19 @@
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";
17
+ import { humanAct } from "../harden/human-act.js";
13
18
  import { readIdentity, writeIdentity } from "../identity/store.js";
14
19
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
15
20
  import { compareIdentity } from "../identity/compare.js";
16
21
  import { createWizard, isTTY } from "../utils/wizard.js";
17
22
 
18
- export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
23
+ export async function identityCommand({ action = "show", config, kjVersion = "unknown", flags = {}, deps = {} }) {
19
24
  const projectDir = config?.projectDir || process.cwd();
20
25
  const log = deps.log || console.log;
21
26
  const tty = deps.isTTY || isTTY;
@@ -27,16 +32,43 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
27
32
 
28
33
  // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
29
34
  if (action === "enroll-phone") {
35
+ let recoveryCode;
30
36
  try {
31
- // KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
32
- // base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
33
- addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
34
- enrollPhone(flags.publicKeyBase64, { home: deps.home });
37
+ // KJC-TSK-0966 (HUM-C, ADR 0018): enrolar es un acto del catálogo; hasta hoy
38
+ // solo lo frenaba el Sentinel, y un kj fuera de Claude no tiene Sentinel.
39
+ humanAct("phone-enroll", { env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, ancestry: deps.ancestry ?? {}, confirm: deps.confirm });
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 });
35
63
  } catch (err) {
36
64
  log(`kj identity enroll-phone: ${err.message}`);
37
65
  return 1;
38
66
  }
39
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
+ }
40
72
  return 0;
41
73
  }
42
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,25 +7,30 @@
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
 
15
- import { confirmHuman, refuseAgentSession } from "../harden/human-act.js";
16
+ import { signAct } from "../harden/act-sign.js";
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";
18
20
  import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } from "./rules.js";
19
21
 
20
- const ACT = "kj rules approve";
21
22
 
22
23
  /**
23
24
  * @param {{projectDir: string, file?: string, home?: string, env?: object, tty?: boolean,
24
25
  * deps?: {confirm?: Function, ancestry?: object}, log?: (line: string) => void}} opts
25
26
  * @returns {Promise<{code: 0|1, lines: string[]}>}
26
27
  */
27
- export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
28
- refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
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 }) {
32
+ const act = humanActOf("rules-approve");
33
+ act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
29
34
  const proposal = path.resolve(projectDir, file);
30
35
  let text;
31
36
  try { text = fs.readFileSync(proposal, "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to approve`] }; }
@@ -48,13 +53,25 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
48
53
  // KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
49
54
  const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
50
55
  for (const line of view) log(line);
51
- confirmHuman(ACT, deps.confirm);
56
+ act.confirm(deps.confirm);
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
+ }
@@ -68,3 +68,41 @@ export function confirmHuman(what, confirm = null) {
68
68
  const answer = confirm ? confirm(nonce) : readTty(what, nonce);
69
69
  if (answer !== nonce) throw new Error(`${what}: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
70
70
  }
71
+
72
+ /**
73
+ * KJC-TSK-0966 (HUM-C, ADR 0018): the CLOSED catalog of human acts. Every
74
+ * command that changes who governs, or that destroys work, is listed here and
75
+ * runs through this module; an architecture test fails on a module of the
76
+ * catalog that skips it and on a writer of governance files that is not here.
77
+ * `module` names the file that implements the act; `pending` names the card
78
+ * that will, for acts the ADR declares and kj does not have yet.
79
+ */
80
+ export const HUMAN_ACTS = Object.freeze({
81
+ "supervisor-seal": { label: "harden --commit", module: "src/harden/supervisor-commit.js" },
82
+ "rules-approve": { label: "kj rules approve", module: "src/commands/rules-approve.js" },
83
+ "phone-enroll": { label: "kj identity enroll-phone", module: "src/commands/identity.js" },
84
+ "handoff-run": { label: "kj handoff run", module: null, pending: "KJC-TSK-0968" },
85
+ "security-level-down": { label: "kj security normal", module: "src/harden/security-level.js" },
86
+ });
87
+
88
+ /**
89
+ * The one mechanism, by catalog id: `refuse` (layers 1 to 3) and `confirm`
90
+ * (layer 4), apart because an act may check things in between (the seal looks
91
+ * at the stage before asking for the nonce). `humanAct` runs both.
92
+ * @param {keyof typeof HUMAN_ACTS} id
93
+ */
94
+ export function humanActOf(id) {
95
+ const act = HUMAN_ACTS[id];
96
+ if (!act) throw new Error(`"${id}" is not in the catalog of human acts (src/harden/human-act.js, ADR 0018)`);
97
+ return {
98
+ label: act.label,
99
+ refuse: (opts = {}) => refuseAgentSession(act.label, opts),
100
+ confirm: (confirm = null) => confirmHuman(act.label, confirm),
101
+ };
102
+ }
103
+
104
+ export function humanAct(id, { env, tty, ancestry, confirm } = {}) {
105
+ const act = humanActOf(id);
106
+ act.refuse({ env, tty, ancestry });
107
+ act.confirm(confirm);
108
+ }
@@ -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
  /**
@@ -164,7 +174,14 @@ export function provenanceSignature({ projectDir, provenance }) {
164
174
  * mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
165
175
  * @returns {Promise<{ok: boolean, reason?: string}>}
166
176
  */
167
- export async function requestPhoneSignature({ project, files, kjVersion, logger = console, deps = {} }) {
177
+ // KJC-TSK-0992 (HUM-G): what the page shows beside the sha256, as Firestore values.
178
+ const str = (s) => ({ stringValue: String(s ?? "") });
179
+ const changesValue = (changes) => ({ arrayValue: { values: changes.map((c) => ({ mapValue: { fields: {
180
+ file: str(c.file), status: str(c.status), added: { integerValue: String(c.added ?? 0) }, removed: { integerValue: String(c.removed ?? 0) },
181
+ summary: str(c.summary), diff: str(c.diff), truncated: { booleanValue: Boolean(c.truncated) },
182
+ } } })) } });
183
+
184
+ export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, why = null, logger = console, deps = {} }) {
168
185
  const fetchFn = deps.fetch ?? fetch;
169
186
  const now = deps.now ?? Date.now;
170
187
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -184,6 +201,12 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
184
201
  createdAt: { timestampValue: new Date(issuedMs).toISOString() },
185
202
  files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
186
203
  ...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
204
+ // KJC-TSK-0992: informative, outside the signed payload; the page says so.
205
+ ...(Array.isArray(changes) ? { changes: changesValue(changes) } : {}),
206
+ ...(branch ? { branch: str(branch) } : {}),
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) } : {}),
187
210
  },
188
211
  };
189
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
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * KJC-TSK-0992 (HUM-G, ADR 0018): what the phone is asked to sign, said.
3
+ *
4
+ * The signing page used to show a project name and a list of files with their
5
+ * sha256: nothing a person can read as "this is what changes". The signature
6
+ * still covers exactly the sha256 (payload v2); this module describes, file by
7
+ * file, what kj regenerated so the page can show it: new, modified or deleted,
8
+ * lines added and removed, a one-line summary and the unified diff, cut to a
9
+ * size the request can carry and scanned for personal data before it leaves.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { scanText } from "../privacy/scan.js";
16
+
17
+ export const DIFF_LIMIT_PER_FILE = 12_000;
18
+ export const DIFF_LIMIT_TOTAL = 400_000;
19
+
20
+ const defaultGit = (projectDir) => (args) => {
21
+ try {
22
+ return execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
23
+ } catch (err) {
24
+ // `git diff --no-index` exits 1 when the files differ: the diff is still on stdout.
25
+ if (args.includes("--no-index") && typeof err?.stdout === "string") return err.stdout;
26
+ throw err;
27
+ }
28
+ };
29
+
30
+ const inHead = (git, file) => { try { git(["cat-file", "-e", `HEAD:${file}`]); return true; } catch { return false; } };
31
+
32
+ /** The first comment kj added, which in a generated hook says why it changed. */
33
+ const summaryOf = (diff) => {
34
+ const comment = diff.split("\n").find((l) => /^\+\s*(#|\/\/)\s*\S/.test(l) && !l.startsWith("+++"));
35
+ return comment ? comment.replace(/^\+\s*(#|\/\/)\s*/, "").trim().slice(0, 160) : "";
36
+ };
37
+
38
+ /** A file git does not know yet: the whole file is the addition. */
39
+ const newFile = (git, projectDir, file) => ({
40
+ raw: git(["diff", "--no-index", "--", "/dev/null", join(projectDir, file)]),
41
+ added: readFileSync(join(projectDir, file), "utf8").split("\n").filter(Boolean).length,
42
+ removed: 0,
43
+ });
44
+ /** A tracked file, changed or deleted: the diff against HEAD and its numstat. */
45
+ const trackedFile = (git, file) => {
46
+ const stat = git(["diff", "--numstat", "HEAD", "--", file]).trim().split("\t");
47
+ return { raw: git(["diff", "HEAD", "--", file]), added: Number.parseInt(stat[0], 10) || 0, removed: Number.parseInt(stat[1], 10) || 0 };
48
+ };
49
+
50
+ const MARKER_ROOM = 60;
51
+ /** Cut `text` so the result, marker included, never exceeds `limit`; no room left is one bounded marker. */
52
+ const cut = (text, limit) => {
53
+ if (text.length <= limit) return { diff: text, truncated: false };
54
+ const keep = Math.max(0, limit - MARKER_ROOM);
55
+ return { diff: `${text.slice(0, keep)}\n… [cut: ${text.length - keep} more characters]`.slice(0, limit), truncated: true };
56
+ };
57
+
58
+ /**
59
+ * @param {{projectDir: string, files: Array<{file: string, deleted?: boolean}>, gitFn?: Function, scan?: Function}} opts
60
+ * @returns {Array<{file: string, status: "new"|"modified"|"deleted", added: number, removed: number, summary: string, diff: string, truncated: boolean}>}
61
+ */
62
+ export function describeSupervisorChanges({ projectDir, files, gitFn = defaultGit(projectDir), scan = scanText }) {
63
+ let budget = DIFF_LIMIT_TOTAL;
64
+ return files.map(({ file, deleted }) => {
65
+ const tracked = inHead(gitFn, file);
66
+ const present = !deleted && existsSync(join(projectDir, file));
67
+ let status = "modified";
68
+ if (deleted || !present) status = "deleted";
69
+ else if (!tracked) status = "new";
70
+ const measured = status === "new" ? newFile(gitFn, projectDir, file) : trackedFile(gitFn, file);
71
+ const { added, removed } = measured;
72
+ // What leaves the machine is scanned like anything kj publishes: a hit
73
+ // replaces the diff, never travels.
74
+ const hits = scan(measured.raw, { source: file }).filter((h) => h.severity === "block");
75
+ const raw = hits.length > 0 ? `[diff withheld: the privacy scan found ${hits.length} sensitive item(s) in ${file}]` : measured.raw;
76
+ // Per file and in total: what the request carries never exceeds the limits.
77
+ const { diff, truncated } = cut(raw, Math.min(DIFF_LIMIT_PER_FILE, Math.max(0, budget)));
78
+ budget = Math.max(0, budget - diff.length);
79
+ return { file, status, added, removed, summary: summaryOf(raw), diff, truncated };
80
+ });
81
+ }
@@ -8,13 +8,14 @@
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 { confirmHuman, refuseAgentSession } from "./human-act.js";
17
- import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
16
+ import { signAct } from "./act-sign.js";
17
+ import { humanActOf } from "./human-act.js";
18
+ import { describeSupervisorChanges } from "./sign-changes.js";
18
19
 
19
20
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
20
21
  const HOOKS_PREFIX = ".karajan/hooks/";
@@ -28,7 +29,6 @@ const HARNESS_PREFIX = ".karajan/harness/";
28
29
 
29
30
  const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
30
31
 
31
- const ACT = "harden --commit";
32
32
 
33
33
  /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
34
34
  /**
@@ -73,7 +73,8 @@ export async function commitSupervisorRegeneration({
73
73
  }) {
74
74
  // El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
75
75
  // Capas 1 a 3 (entorno, tty y ascendencia de procesos): src/harden/human-act.js.
76
- refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
76
+ const seal = humanActOf("supervisor-seal");
77
+ seal.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
77
78
  const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
78
79
  // KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
79
80
  // un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
@@ -86,7 +87,7 @@ export async function commitSupervisorRegeneration({
86
87
  }
87
88
  // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
88
89
  // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
89
- confirmHuman(ACT, deps.confirm);
90
+ seal.confirm(deps.confirm);
90
91
  const drift = supervisorDrift({ projectDir, gitFn: run });
91
92
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
92
93
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
@@ -119,27 +120,18 @@ export async function commitSupervisorRegeneration({
119
120
  logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
120
121
  return { committed: false, reason: "sin drift" };
121
122
  }
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
- let signatureBlock = null;
127
- if (phone.enrolled({})) {
128
- const signed = await phone.request({
129
- project: basename(projectDir),
130
- files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
131
- kjVersion,
132
- logger,
133
- });
134
- if (!signed.ok) {
135
- throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
136
- }
137
- // KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
138
- // re-verifique server-side que un humano (clave del padrón) aprobó.
139
- if (signed.challenge && signed.signature) {
140
- signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
141
- }
142
- }
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
+ });
143
135
  const who = readIdentity(projectDir);
144
136
  const provenance = {
145
137
  kj_version: kjVersion,
@@ -8,7 +8,8 @@ import { HuReviewerRole } from "../../roles/hu-reviewer-role.js";
8
8
  import { createAgent } from "../../agents/index.js";
9
9
  import { addCheckpoint } from "../../session/store.js";
10
10
  import { emitProgress, makeEvent } from "../../utils/events.js";
11
- import { buildDecompositionPrompt, parseDecompositionOutput } from "../../prompts/hu-reviewer.js";
11
+ import { buildDecompositionPrompt, parseDecompositionOutput, uncoveredCriteria } from "../../prompts/hu-reviewer.js";
12
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
12
13
  import { createStallDetector } from "../../utils/stall-detector.js";
13
14
  import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../../hu/store.js";
14
15
  import { topologicalSort } from "../../hu/graph.js";
@@ -20,7 +21,10 @@ import { detectAndApplySplits } from "./hu-reviewer-phases/detect-and-apply-spli
20
21
  */
21
22
  async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
22
23
  const provider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
23
- const prompt = buildDecompositionPrompt(session.task);
24
+ // KJC-TSK-0989 (#1894): the card's criteria go into the decomposition, numbered,
25
+ // so each one lands in an HU; the ones no HU covers are said and kept, never lost.
26
+ const criteria = criteriaOfCard(session.pg_card);
27
+ const prompt = buildDecompositionPrompt(session.task, { criteria });
24
28
 
25
29
  emitProgress(
26
30
  emitter,
@@ -70,6 +74,19 @@ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, sessio
70
74
  logger.info("HU decomposition returned < 2 stories, falling back to single auto-story");
71
75
  return null;
72
76
  }
77
+ const uncovered = uncoveredCriteria(stories, criteria);
78
+ if (uncovered.length > 0) {
79
+ const last = stories.at(-1);
80
+ const tags = uncovered.map((c) => `[AC-${c.index}]`);
81
+ last.acceptanceCriteria.push(...uncovered.map((c, i) => `${tags[i]} ${c.text}`));
82
+ last.covers.push(...uncovered.map((c) => c.index));
83
+ logger.warn(`HU decomposition left ${uncovered.length} card criteria with no HU; added to ${last.id}: ${tags.join(" ")}`);
84
+ emitProgress(emitter, makeEvent("hu-reviewer:criteria-uncovered", { ...eventBase, stage: "hu-reviewer" }, {
85
+ status: "warn",
86
+ message: `${uncovered.length} card criteria had no HU and were added to ${last.id}`,
87
+ detail: { uncovered, addedTo: last.id }
88
+ }));
89
+ }
73
90
 
74
91
  emitProgress(
75
92
  emitter,
@@ -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
+ }
@@ -136,7 +136,7 @@ export function normalizeAcceptanceCriteria(criteria) {
136
136
  * @param {string} task - The complex task description to decompose.
137
137
  * @returns {string} The assembled decomposition prompt.
138
138
  */
139
- export function buildDecompositionPrompt(task) {
139
+ export function buildDecompositionPrompt(task, { criteria = [] } = {}) {
140
140
  const sections = [SUBAGENT_PREAMBLE];
141
141
 
142
142
  sections.push("## Task Decomposition");
@@ -147,20 +147,41 @@ export function buildDecompositionPrompt(task) {
147
147
 
148
148
  sections.push(`## Task to Decompose\n${task}`);
149
149
 
150
+ // KJC-TSK-0989 (#1894): the card's criteria are the contract; each one must
151
+ // land in an HU, by number, or the run implements stories of its own invention.
152
+ if (criteria.length > 0) {
153
+ const numbered = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
154
+ sections.push(`## Acceptance criteria of the card (the contract)\n${numbered}`);
155
+ }
156
+
150
157
  sections.push(
151
158
  "Return a single valid JSON object and nothing else.",
152
- `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string]}]}`,
159
+ `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number]}]}`,
153
160
  "Rules:",
154
161
  "- Generate between 2 and 5 HUs",
155
162
  "- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
156
163
  "- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
157
164
  "- acceptanceCriteria should be concrete, testable statements",
158
- "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)"
165
+ "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)",
166
+ ...(criteria.length > 0 ? [
167
+ "- covers lists the numbers of the card's acceptance criteria this HU implements; EVERY card criterion must appear in the covers of at least one HU, and an HU's acceptanceCriteria must include each criterion it covers, verbatim",
168
+ ] : ["- covers is an empty array"])
159
169
  );
160
170
 
161
171
  return sections.join("\n\n");
162
172
  }
163
173
 
174
+ /**
175
+ * KJC-TSK-0989: the card's criteria no HU covers, by number and text.
176
+ * @param {Array<{covers?: number[]}>} stories
177
+ * @param {string[]} criteria
178
+ * @returns {Array<{index: number, text: string}>}
179
+ */
180
+ export function uncoveredCriteria(stories, criteria) {
181
+ const covered = new Set(stories.flatMap((s) => s.covers ?? []));
182
+ return criteria.map((text, i) => ({ index: i + 1, text })).filter((c) => !covered.has(c.index));
183
+ }
184
+
164
185
  /**
165
186
  * Parse the output from a decomposition prompt.
166
187
  * @param {string} raw - Raw text output from the agent.
@@ -179,7 +200,8 @@ export function parseDecompositionOutput(raw) {
179
200
  goal: s.goal || s.title,
180
201
  benefit: s.benefit || "",
181
202
  acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
182
- dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : []
203
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
204
+ covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : []
183
205
  }));
184
206
 
185
207
  return stories.length > 0 ? stories : null;
@@ -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
  /**