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 +1 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +26 -2
- package/src/commands/identity.js +38 -6
- package/src/commands/init.js +24 -0
- package/src/commands/policy.js +9 -3
- package/src/commands/rules-approve.js +24 -7
- package/src/commands/security.js +45 -0
- package/src/environment/contract-commit.js +2 -0
- package/src/harden/act-sign.js +69 -0
- package/src/harden/human-act.js +38 -0
- package/src/harden/phone-sign.js +32 -9
- package/src/harden/roster.js +116 -0
- package/src/harden/security-level.js +94 -0
- package/src/harden/sign-changes.js +81 -0
- package/src/harden/supervisor-commit.js +19 -27
- package/src/orchestrator/stages/hu-reviewer-stage.js +19 -2
- package/src/policy/roster-check.js +34 -0
- package/src/prompts/hu-reviewer.js +26 -4
- package/src/review/gate-gitignore.js +2 -0
package/package.json
CHANGED
|
@@ -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
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
});
|
package/src/commands/identity.js
CHANGED
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
* no session and no flags → exit 1.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/policy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/src/harden/human-act.js
CHANGED
|
@@ -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
|
+
}
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
152
|
-
if (entries.some((entry) => typeof entry?.file !== "string")) return
|
|
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 ? {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
//
|
|
124
|
-
// files/hashes
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
/**
|