karajan-code 4.46.0 → 4.48.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 +37 -2
- package/src/commands/handoff.js +38 -0
- package/src/commands/identity.js +34 -6
- package/src/commands/init.js +24 -0
- package/src/commands/policy.js +9 -3
- package/src/commands/rules-approve.js +20 -3
- 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/handoff.js +60 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/human-act.js +1 -1
- package/src/harden/phone-sign.js +21 -9
- package/src/harden/roster.js +116 -0
- package/src/harden/security-level.js +94 -0
- package/src/harden/sentinel/sentinel-shell.mjs +29 -2
- package/src/harden/sentinel-hooks.js +17 -6
- package/src/harden/supervisor-commit.js +14 -29
- package/src/policy/roster-check.js +34 -0
- 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", "handoff"] },
|
|
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,8 @@ 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";
|
|
12
|
+
import { handoffCommand } from "../commands/handoff.js";
|
|
11
13
|
import { policyCommand } from "../commands/policy.js";
|
|
12
14
|
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
13
15
|
import { stewardSweepCommand } from "../commands/steward.js";
|
|
@@ -163,12 +165,45 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
163
165
|
.command("enroll-phone")
|
|
164
166
|
.description("Enroll the phone's PUBLIC signing key (base64 of the raw 32-byte ed25519 key) for the supervisor seal")
|
|
165
167
|
.argument("<publicKeyBase64>", "raw ed25519 public key, base64 (shown by the phone app)")
|
|
168
|
+
.option("--label <name>", "A name for this phone in the roster")
|
|
169
|
+
.option("--recovery <code>", "Replace a lost phone with the recovery code shown at enrollment (spent on use, a new one is issued)")
|
|
170
|
+
.option("--replaces <publicKeyBase64>", "With --recovery: the lost phone to revoke (default: the one enrolled on this machine)")
|
|
166
171
|
.action(async (publicKeyBase64, flags) => {
|
|
167
172
|
await withConfig(pkgVersion, "identity-enroll-phone", flags, async ({ config }) => {
|
|
168
|
-
process.exitCode = await identityCommand({ action: "enroll-phone", config, flags: { ...flags, publicKeyBase64 } });
|
|
173
|
+
process.exitCode = await identityCommand({ action: "enroll-phone", config, kjVersion: pkgVersion, flags: { ...flags, publicKeyBase64 } });
|
|
169
174
|
});
|
|
170
175
|
});
|
|
171
176
|
|
|
177
|
+
// KJC-TSK-0995 (HUM-D, ADR 0018): the project's security level; going down is a signed act.
|
|
178
|
+
const securityCmd = program
|
|
179
|
+
.command("security")
|
|
180
|
+
.description("Security level of this project: max (every catalog act signed by the phone) or normal (four layers, lesser guarantee)");
|
|
181
|
+
const SECURITY_ACTIONS = {
|
|
182
|
+
show: "The level in force, where it comes from and whether a phone is enrolled (exit 2 on a record that cannot be trusted)",
|
|
183
|
+
max: "Raise to max: no signature needed",
|
|
184
|
+
normal: "Lower to normal: a human act signed by the enrolled phone (refused without one)",
|
|
185
|
+
};
|
|
186
|
+
for (const [action, description] of Object.entries(SECURITY_ACTIONS)) {
|
|
187
|
+
securityCmd
|
|
188
|
+
.command(action, action === "show" ? { isDefault: true } : {})
|
|
189
|
+
.description(description)
|
|
190
|
+
.action(async (flags) => {
|
|
191
|
+
await withConfig(pkgVersion, `security-${action}`, flags, async ({ config }) => {
|
|
192
|
+
process.exitCode = await securityCommand({ action, config, kjVersion: pkgVersion, flags });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through kj.
|
|
198
|
+
const handoffCmd = program.command("handoff").description("Handoffs: what a gate denied the agent, registered with the gate's why, for the human to read and run by id");
|
|
199
|
+
const handoff = (action, flags) => withConfig(pkgVersion, `handoff-${action}`, flags, async ({ config }) => { process.exitCode = await handoffCommand({ action, config, flags }); });
|
|
200
|
+
handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you)")
|
|
201
|
+
.argument("<command...>", "the denied command, as it was run").option("--cwd <dir>", "Directory the command runs in (default: the project)")
|
|
202
|
+
.action((parts, flags) => handoff("add", { ...flags, command: parts.join(" ") }));
|
|
203
|
+
handoffCmd.command("list", { isDefault: true }).description("The handoffs registered in this project").action((flags) => handoff("list", flags));
|
|
204
|
+
handoffCmd.command("show").description("A handoff: the exact command, its sha256 and why the gate denied it")
|
|
205
|
+
.argument("<id>", "handoff id (8 hex)").option("--json", "Output the record as JSON").action((id, flags) => handoff("show", { ...flags, id }));
|
|
206
|
+
|
|
172
207
|
// KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
|
|
173
208
|
identityCmd
|
|
174
209
|
.command("phone-invite")
|
|
@@ -412,7 +447,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
412
447
|
.option("--file <path>", "The proposal", PROPOSAL_FILE)
|
|
413
448
|
.action(async (flags) => {
|
|
414
449
|
await withConfig(pkgVersion, "rules-approve", flags, async ({ config }) => {
|
|
415
|
-
const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
|
|
450
|
+
const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file, kjVersion: pkgVersion });
|
|
416
451
|
for (const line of res.lines) console.log(line);
|
|
417
452
|
process.exitCode = res.code;
|
|
418
453
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj handoff add|list|show (KJC-TSK-0968, HUM-E, ADR 0018): the agent registers
|
|
3
|
+
* what a gate denied it, with the gate's why; the human reads it by id.
|
|
4
|
+
*/
|
|
5
|
+
import { addHandoff, listHandoffs, readHandoff } from "../harden/handoff.js";
|
|
6
|
+
|
|
7
|
+
const line = (h) => `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}`;
|
|
8
|
+
|
|
9
|
+
/** @returns {Promise<0|1>} */
|
|
10
|
+
export async function handoffCommand({ action = "list", config, flags = {}, log = console.log }) {
|
|
11
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
12
|
+
try {
|
|
13
|
+
if (action === "add") {
|
|
14
|
+
const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir });
|
|
15
|
+
log(`encargo ${record.id} registrado: ${record.command}`);
|
|
16
|
+
log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
|
|
17
|
+
log(`tu usuario lo revisa con kj handoff show ${record.id}; nadie copia y pega el comando.`);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (action === "show") {
|
|
21
|
+
const h = readHandoff({ projectDir, id: flags.id });
|
|
22
|
+
if (flags.json) { log(JSON.stringify(h)); return 0; }
|
|
23
|
+
log(`encargo ${h.id} (${h.status}, ${h.createdAt})`);
|
|
24
|
+
log(`comando ${h.command}`);
|
|
25
|
+
log(`sha256 ${h.sha256}`);
|
|
26
|
+
log(`cwd ${h.cwd}`);
|
|
27
|
+
log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const all = listHandoffs({ projectDir });
|
|
31
|
+
if (all.length === 0) log("kj handoff: ningún encargo registrado");
|
|
32
|
+
for (const h of all) log(line(h));
|
|
33
|
+
return 0;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(err.message);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/commands/identity.js
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
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";
|
|
13
17
|
import { humanAct } from "../harden/human-act.js";
|
|
14
18
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
@@ -16,7 +20,7 @@ import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
|
16
20
|
import { compareIdentity } from "../identity/compare.js";
|
|
17
21
|
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
18
22
|
|
|
19
|
-
export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
|
|
23
|
+
export async function identityCommand({ action = "show", config, kjVersion = "unknown", flags = {}, deps = {} }) {
|
|
20
24
|
const projectDir = config?.projectDir || process.cwd();
|
|
21
25
|
const log = deps.log || console.log;
|
|
22
26
|
const tty = deps.isTTY || isTTY;
|
|
@@ -28,19 +32,43 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
28
32
|
|
|
29
33
|
// KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
|
|
30
34
|
if (action === "enroll-phone") {
|
|
35
|
+
let recoveryCode;
|
|
31
36
|
try {
|
|
32
37
|
// KJC-TSK-0966 (HUM-C, ADR 0018): enrolar es un acto del catálogo; hasta hoy
|
|
33
38
|
// solo lo frenaba el Sentinel, y un kj fuera de Claude no tiene Sentinel.
|
|
34
39
|
humanAct("phone-enroll", { env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, ancestry: deps.ancestry ?? {}, confirm: deps.confirm });
|
|
35
|
-
// KJC-TSK-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// KJC-TSK-0998 (HUM-D, ADR 0018): a phone of the roster signs the key that
|
|
41
|
+
// enters; the FIRST one has nobody to sign it (bootstrap) and runs on the four layers.
|
|
42
|
+
const key = validatePhonePublicKey(flags.publicKeyBase64);
|
|
43
|
+
const signers = ".karajan/supervisor-signers.json";
|
|
44
|
+
// KJC-TSK-0999 (HUM-B2, ADR 0018): a lost phone is replaced with the recovery
|
|
45
|
+
// code, never by reinstalling: its proof is the admission and the lost phone
|
|
46
|
+
// is not asked to sign. The code is checked BEFORE anything else happens.
|
|
47
|
+
const recovered = flags.recovery ? redeemRecoveryCode(flags.recovery, { projectDir }) : null;
|
|
48
|
+
// The lost phone is REVOKED by the same code: the one named with --replaces, or the one this machine had enrolled.
|
|
49
|
+
const lost = recovered ? (flags.replaces ?? (isPhoneEnrolled({ home: deps.home }) ? readEnrolledKey({ home: deps.home }) : null)) : null;
|
|
50
|
+
if (recovered && !lost) throw new Error("di qué móvil se pierde: --replaces <su clave pública> (esta máquina no tiene ninguno enrolado)");
|
|
51
|
+
if (recovered) log(`recuperación con el código: el móvil perdido (${lost.slice(0, 8)}…) queda revocado y no firma; el código gastado es la admisión del nuevo`);
|
|
52
|
+
const { signature } = await signAct("phone-enroll", {
|
|
53
|
+
projectDir, home: deps.home, kjVersion, logger: { warn: log, info: log },
|
|
54
|
+
deps: recovered ? { ...deps, phone: { enrolled: () => false } } : deps,
|
|
55
|
+
bootstrap: recovered !== null || readEnrolledKeys({ projectDir, home: deps.home }).size === 0,
|
|
56
|
+
files: [{ file: signers, sha256: createHash("sha256").update(key).digest("hex") }],
|
|
57
|
+
changes: [{ file: signers, status: "modified", added: 1, removed: 0, summary: `Entra la clave ${flags.label ?? "(sin etiqueta)"}`, diff: `+ ${key}`, truncated: false }],
|
|
58
|
+
});
|
|
59
|
+
// KJC-TSK-0831 (ADR 0010): the versioned roster, now with the admission CI
|
|
60
|
+
// verifies (HUM-B); the legacy key in ~/.karajan stays.
|
|
61
|
+
recoveryCode = admitSigner({ projectDir, publicKey: key, label: flags.label ?? null, admission: recovered ?? signature, replaces: lost }).recoveryCode;
|
|
62
|
+
enrollPhone(key, { home: deps.home });
|
|
39
63
|
} catch (err) {
|
|
40
64
|
log(`kj identity enroll-phone: ${err.message}`);
|
|
41
65
|
return 1;
|
|
42
66
|
}
|
|
43
67
|
log("Móvil enrolado: la clave pública se añadió al padrón .karajan/supervisor-signers.json (commítealo) y a ~/.karajan/supervisor-phone.json. Sellar el supervisor pedirá la firma de un móvil del padrón.");
|
|
68
|
+
if (recoveryCode) {
|
|
69
|
+
log(`\nCÓDIGO DE RECUPERACIÓN, se enseña UNA sola vez. Guárdalo fuera de esta máquina:\n\n ${recoveryCode}\n`);
|
|
70
|
+
log("Sirve para enrolar un móvil nuevo si pierdes este: kj identity enroll-phone <clave nueva> --recovery <código>. Es de un solo uso: al usarlo se emite otro. Sin móvil y sin código no hay recuperación: el padrón se rehace por fuera de CI, a la vista de todos (ADR 0018).");
|
|
71
|
+
}
|
|
44
72
|
return 0;
|
|
45
73
|
}
|
|
46
74
|
|
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,11 +7,13 @@
|
|
|
7
7
|
* The proposal is read ONCE: what is checked, what is shown and what is
|
|
8
8
|
* installed are the same rules, whatever happens to the file meanwhile.
|
|
9
9
|
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
|
|
13
14
|
import yaml from "js-yaml";
|
|
14
15
|
|
|
16
|
+
import { signAct } from "../harden/act-sign.js";
|
|
15
17
|
import { humanActOf } from "../harden/human-act.js";
|
|
16
18
|
import { checkVerdict } from "../review/verdict-store.js";
|
|
17
19
|
import { approvalView } from "../rules/approval-view.js";
|
|
@@ -23,7 +25,10 @@ import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } fr
|
|
|
23
25
|
* deps?: {confirm?: Function, ancestry?: object}, log?: (line: string) => void}} opts
|
|
24
26
|
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
25
27
|
*/
|
|
26
|
-
|
|
28
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
29
|
+
const lines = (text) => (text ? text.split("\n").length - 1 : 0);
|
|
30
|
+
|
|
31
|
+
export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, kjVersion = "unknown", env, tty, deps = {}, log = console.log }) {
|
|
27
32
|
const act = humanActOf("rules-approve");
|
|
28
33
|
act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
|
|
29
34
|
const proposal = path.resolve(projectDir, file);
|
|
@@ -52,9 +57,21 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
|
|
|
52
57
|
// KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
|
|
53
58
|
// is not versioned. Where it goes is the inventory's word, not the proposal's.
|
|
54
59
|
const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
|
|
55
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through
|
|
3
|
+
* kj. When a gate denies the agent a command, the agent registers a HANDOFF with
|
|
4
|
+
* the exact command; the why is what the gate said, read from the Sentinel's
|
|
5
|
+
* record of denials, never a text of the agent. The human later reads and runs
|
|
6
|
+
* it by id (E2, E3). Handoffs live in .karajan/handoffs/<id>.json, local.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const HANDOFFS_DIR = ".karajan/handoffs";
|
|
13
|
+
export const DENIALS_FILE = ".karajan/harness/denials.jsonl";
|
|
14
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
15
|
+
|
|
16
|
+
/** The Sentinel's denials, oldest first; a line that is not JSON is skipped. */
|
|
17
|
+
export function readDenials({ projectDir }) {
|
|
18
|
+
let text;
|
|
19
|
+
try { text = readFileSync(join(projectDir, DENIALS_FILE), "utf8"); } catch { return []; }
|
|
20
|
+
return text.split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The latest denial of this EXACT Bash command, or null. */
|
|
24
|
+
export function findDenial({ projectDir, command }) {
|
|
25
|
+
return readDenials({ projectDir }).findLast((d) => d?.tool === "Bash" && d?.input?.command === command) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers a handoff for a command a gate denied. Without a denial of those
|
|
30
|
+
* exact bytes there is nothing to hand off: the agent does not write the why.
|
|
31
|
+
* @returns {{id: string, command: string, sha256: string, why: string}}
|
|
32
|
+
*/
|
|
33
|
+
export function addHandoff({ projectDir, command, cwd = projectDir }) {
|
|
34
|
+
if (typeof command !== "string" || !command.trim()) throw new Error("kj handoff add: falta el comando exacto que el gate negó");
|
|
35
|
+
const denial = findDenial({ projectDir, command });
|
|
36
|
+
if (!denial) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018)");
|
|
37
|
+
const createdAt = new Date().toISOString();
|
|
38
|
+
const digest = sha256(command);
|
|
39
|
+
const id = sha256(`${digest}:${createdAt}`).slice(0, 8);
|
|
40
|
+
const record = { id, command, sha256: digest, cwd, createdAt, status: "pending", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
|
|
41
|
+
mkdirSync(join(projectDir, HANDOFFS_DIR), { recursive: true });
|
|
42
|
+
writeFileSync(join(projectDir, HANDOFFS_DIR, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readHandoff({ projectDir, id }) {
|
|
47
|
+
if (!/^[0-9a-f]{8}$/.test(id ?? "")) throw new Error(`kj handoff: "${id}" no es un identificador de encargo (8 hex)`);
|
|
48
|
+
const path = join(projectDir, HANDOFFS_DIR, `${id}.json`);
|
|
49
|
+
if (!existsSync(path)) throw new Error(`kj handoff: no hay ningún encargo ${id}`);
|
|
50
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
51
|
+
// The bytes the human will run are the bytes that were registered, or nothing.
|
|
52
|
+
if (sha256(record.command ?? "") !== record.sha256) throw new Error(`kj handoff: el encargo ${id} no coincide con su sha256: alguien lo tocó, no se ejecuta`);
|
|
53
|
+
return record;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listHandoffs({ projectDir }) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(join(projectDir, HANDOFFS_DIR)).filter((n) => /^[0-9a-f]{8}\.json$/.test(n)); } catch { return []; }
|
|
59
|
+
return names.map((n) => readHandoff({ projectDir, id: n.slice(0, 8) })).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
60
|
+
}
|
|
@@ -135,8 +135,9 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
135
135
|
"fi",
|
|
136
136
|
"# KJC-BUG-0296 (issue #1989): the host agent's runtime and local state never",
|
|
137
137
|
"# enter a commit, however they were staged (a blanket `git add -A` put a",
|
|
138
|
-
"# Claude scheduler lock into main). Checked on the staged list itself
|
|
139
|
-
"
|
|
138
|
+
"# Claude scheduler lock into main). Checked on the staged list itself,",
|
|
139
|
+
"# deletions left out: untracking a runtime file is the fix (KJC-BUG-0307).",
|
|
140
|
+
"if git diff --cached --name-only --diff-filter=d | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
|
|
140
141
|
" echo 'kj harden: agent runtime files are staged (.claude/*.lock, .kj/, identity.local.yml, sentinel-state.json) — unstage them: git restore --staged <path>'; exit 1",
|
|
141
142
|
"fi",
|
|
142
143
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
package/src/harden/human-act.js
CHANGED
|
@@ -82,7 +82,7 @@ export const HUMAN_ACTS = Object.freeze({
|
|
|
82
82
|
"rules-approve": { label: "kj rules approve", module: "src/commands/rules-approve.js" },
|
|
83
83
|
"phone-enroll": { label: "kj identity enroll-phone", module: "src/commands/identity.js" },
|
|
84
84
|
"handoff-run": { label: "kj handoff run", module: null, pending: "KJC-TSK-0968" },
|
|
85
|
-
"security-level-down": { label: "kj security normal", module:
|
|
85
|
+
"security-level-down": { label: "kj security normal", module: "src/harden/security-level.js" },
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
/**
|
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
|
/**
|
|
@@ -171,7 +181,7 @@ const changesValue = (changes) => ({ arrayValue: { values: changes.map((c) => ({
|
|
|
171
181
|
summary: str(c.summary), diff: str(c.diff), truncated: { booleanValue: Boolean(c.truncated) },
|
|
172
182
|
} } })) } });
|
|
173
183
|
|
|
174
|
-
export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, logger = console, deps = {} }) {
|
|
184
|
+
export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, why = null, logger = console, deps = {} }) {
|
|
175
185
|
const fetchFn = deps.fetch ?? fetch;
|
|
176
186
|
const now = deps.now ?? Date.now;
|
|
177
187
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -195,6 +205,8 @@ export async function requestPhoneSignature({ project, files, kjVersion, changes
|
|
|
195
205
|
...(Array.isArray(changes) ? { changes: changesValue(changes) } : {}),
|
|
196
206
|
...(branch ? { branch: str(branch) } : {}),
|
|
197
207
|
...(origin ? { origin: str(origin) } : {}),
|
|
208
|
+
// KJC-TSK-0996 (HUM-D): what is done, why the signature is asked, what if another did it.
|
|
209
|
+
...(why ? { why: str(why) } : {}),
|
|
198
210
|
},
|
|
199
211
|
};
|
|
200
212
|
const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0965 (HUM-B, ADR 0018): the roster of signing phones and its recovery
|
|
3
|
+
* code. A phone enters with an ADMISSION: a present phone's signature over the
|
|
4
|
+
* sha256 of its key, or the recovery code, spent. The code is shown once and NEVER
|
|
5
|
+
* stored: the roster keeps sha256(sha256(code)), and spending it reveals sha256(code),
|
|
6
|
+
* which only the holder can produce. CI judges a change with `verifyRosterChange`.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { validatePhonePublicKey, verifySignedFiles } from "./phone-sign.js";
|
|
13
|
+
|
|
14
|
+
export const SIGNERS_FILE = ".karajan/supervisor-signers.json";
|
|
15
|
+
const CODE_RE = /^kjrc(-[a-z2-7]{5}){4}$/;
|
|
16
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
17
|
+
const short = (key) => `${String(key).slice(0, 8)}…`;
|
|
18
|
+
const validKey = (key) => { try { validatePhonePublicKey(key); return true; } catch { return false; } };
|
|
19
|
+
|
|
20
|
+
/** 100 random bits as kjrc-xxxxx-xxxxx-xxxxx-xxxxx; the roster keeps the fingerprint only. */
|
|
21
|
+
export function issueRecoveryCode(random = randomBytes) {
|
|
22
|
+
const chars = [...random(20)].map((b) => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]);
|
|
23
|
+
const code = `kjrc-${[0, 5, 10, 15].map((i) => chars.slice(i, i + 5).join("")).join("-")}`;
|
|
24
|
+
return { code, fingerprint: sha256(sha256(code)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a present phone signs to admit a key: the roster file and the key's sha256. */
|
|
28
|
+
export const admissionFiles = (publicKey) => [{ file: SIGNERS_FILE, sha256: sha256(publicKey) }];
|
|
29
|
+
|
|
30
|
+
const normalize = (doc) => ({ signers: Array.isArray(doc?.signers) ? doc.signers.filter((s) => typeof s?.publicKey === "string") : [], recovery: doc?.recovery ?? null });
|
|
31
|
+
|
|
32
|
+
export function readRoster({ projectDir }) {
|
|
33
|
+
try { return normalize(JSON.parse(readFileSync(join(projectDir, SIGNERS_FILE), "utf8"))); } catch { return normalize(null); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The code must match the roster's fingerprint; returns the admission (its proof, not the code). Writes nothing. */
|
|
37
|
+
export function redeemRecoveryCode(code, { projectDir }) {
|
|
38
|
+
if (!CODE_RE.test(code ?? "")) throw new Error("el código de recuperación no tiene el formato kjrc-xxxxx-xxxxx-xxxxx-xxxxx");
|
|
39
|
+
const { recovery } = readRoster({ projectDir });
|
|
40
|
+
if (!recovery?.fingerprint) throw new Error("este padrón no tiene código de recuperación: se emite al enrolar el primer móvil");
|
|
41
|
+
const proof = sha256(code);
|
|
42
|
+
if (sha256(proof) !== recovery.fingerprint) throw new Error("el código de recuperación no es el de este padrón");
|
|
43
|
+
return { recoveryProof: proof };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Adds a signer with its admission and writes the roster. The first signer, a spent
|
|
48
|
+
* code and a roster with no code yet issue a NEW code, returned once and never stored.
|
|
49
|
+
*/
|
|
50
|
+
export function admitSigner({ projectDir, publicKey, label = null, admission = null, replaces = null, random }) {
|
|
51
|
+
validatePhonePublicKey(publicKey);
|
|
52
|
+
const roster = readRoster({ projectDir });
|
|
53
|
+
if (roster.signers.some((s) => s.publicKey === publicKey)) return { roster, recoveryCode: null };
|
|
54
|
+
const first = roster.signers.length === 0;
|
|
55
|
+
if (!first && !admission) throw new Error("un móvil nuevo entra firmado por uno del padrón o con el código de recuperación (ADR 0018)");
|
|
56
|
+
const enrolledAt = new Date().toISOString();
|
|
57
|
+
// KJC-TSK-0999 (HUM-B2): the lost phone the code replaces is REVOKED by the same
|
|
58
|
+
// spent code; its entry stays, marked, so CI can see the revocation is backed.
|
|
59
|
+
if (replaces && !admission?.recoveryProof) throw new Error("sustituir un móvil se hace con el código de recuperación");
|
|
60
|
+
if (replaces && !roster.signers.some((s) => s.publicKey === replaces && !s.revoked)) throw new Error(`el móvil a sustituir (${short(replaces)}) no está en el padrón`);
|
|
61
|
+
const kept = roster.signers.map((s) => (s.publicKey === replaces ? { ...s, revoked: { at: enrolledAt, recoveryProof: admission.recoveryProof } } : s));
|
|
62
|
+
const next = { signers: [...kept, { publicKey, label, enrolledAt, ...(admission ? { admission } : {}) }], recovery: roster.recovery };
|
|
63
|
+
let recoveryCode = null;
|
|
64
|
+
if (first || admission?.recoveryProof || !roster.recovery) {
|
|
65
|
+
const issued = issueRecoveryCode(random);
|
|
66
|
+
recoveryCode = issued.code;
|
|
67
|
+
next.recovery = { fingerprint: issued.fingerprint, issuedAt: enrolledAt };
|
|
68
|
+
}
|
|
69
|
+
const path = join(projectDir, SIGNERS_FILE);
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
72
|
+
return { roster: next, recoveryCode };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* CI's judgment of a roster change: every key that enters carries a valid admission,
|
|
77
|
+
* no key leaves, and the code's sha256 rotates only when one is spent (once).
|
|
78
|
+
* @returns {{ok: boolean, reason?: string, bootstrap?: boolean, added?: number, spent?: boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function verifyRosterChange({ before, after }) {
|
|
81
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
82
|
+
const prev = normalize(before);
|
|
83
|
+
const next = normalize(after);
|
|
84
|
+
// `known`: every key the previous roster had, revoked or not (a revoked one is not new);
|
|
85
|
+
// `present`: the ones still allowed to sign, the only ones that can admit a key.
|
|
86
|
+
const known = new Set(prev.signers.map((s) => s.publicKey));
|
|
87
|
+
const present = new Set(prev.signers.filter((s) => !s.revoked).map((s) => s.publicKey));
|
|
88
|
+
const fresh = /^[0-9a-f]{64}$/.test(next.recovery?.fingerprint ?? "") ? next.recovery.fingerprint : null;
|
|
89
|
+
const gone = prev.signers.find((s) => !next.signers.some((n) => n.publicKey === s.publicKey));
|
|
90
|
+
if (gone) return fail(`el padrón pierde la clave ${short(gone.publicKey)}: una baja no tiene mecanismo en kj y solo se hace por fuera de CI, a la vista`);
|
|
91
|
+
const back = prev.signers.find((s) => s.revoked && !next.signers.find((n) => n.publicKey === s.publicKey)?.revoked);
|
|
92
|
+
if (back) return fail(`la clave ${short(back.publicKey)} estaba revocada y una revocación no se deshace`);
|
|
93
|
+
// KJC-TSK-0999: a key is revoked only by the recovery code spent in this same change.
|
|
94
|
+
const backed = (proof) => sha256(String(proof ?? "")) === prev.recovery?.fingerprint;
|
|
95
|
+
const revoked = next.signers.filter((n) => n.revoked && !prev.signers.find((s) => s.publicKey === n.publicKey)?.revoked);
|
|
96
|
+
if (revoked.length > 1) return fail("un código de recuperación revoca UN móvil, y este cambio revoca varios");
|
|
97
|
+
if (revoked.length === 1 && !backed(revoked[0].revoked.recoveryProof)) return fail(`la clave ${short(revoked[0].publicKey)} se revoca sin el código de recuperación de este padrón`);
|
|
98
|
+
if (known.size === 0) return next.signers.length === 1 && fresh && validKey(next.signers[0].publicKey) ? { ok: true, bootstrap: true } : fail("el primer padrón lleva UN móvil (clave ed25519 válida) y la huella de su código de recuperación; los demás entran admitidos");
|
|
99
|
+
const added = next.signers.filter((n) => !known.has(n.publicKey));
|
|
100
|
+
let spent = false;
|
|
101
|
+
for (const entry of added) {
|
|
102
|
+
if (!validKey(entry.publicKey)) return fail(`la clave ${short(entry.publicKey)} no es una clave ed25519 válida (base64 de 32 bytes)`);
|
|
103
|
+
const admission = entry.admission ?? null;
|
|
104
|
+
if (admission?.recoveryProof) {
|
|
105
|
+
if (spent) return fail("un código de recuperación admite UNA clave, y este cambio mete dos con él");
|
|
106
|
+
if (!backed(admission.recoveryProof)) return fail(`la clave ${short(entry.publicKey)} entra con un código de recuperación que no es el del padrón anterior`);
|
|
107
|
+
spent = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const check = verifySignedFiles({ sig: admission, files: admissionFiles(entry.publicKey), keys: present });
|
|
111
|
+
if (!check.ok) return fail(`la clave ${short(entry.publicKey)} entra sin admisión válida: ${check.reason}`);
|
|
112
|
+
}
|
|
113
|
+
if (spent && (!fresh || fresh === prev.recovery?.fingerprint)) return fail("el código de recuperación se gastó y el padrón no trae la huella de uno nuevo");
|
|
114
|
+
if (!spent && (prev.recovery ? fresh !== prev.recovery.fingerprint : added.length > 0 && !fresh)) return fail("la huella del código de recuperación cambia sin que se haya gastado ninguno, o falta cuando entra un móvil en un padrón sin código");
|
|
115
|
+
return { ok: true, added: added.length, spent };
|
|
116
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0967 (HUM-D, ADR 0018): the project's security level, versioned in
|
|
3
|
+
* .karajan/security-level.json: `max` (every catalog act signed by the phone
|
|
4
|
+
* over the exact bytes) or `normal` (the four layers of ADR 0009, a lesser
|
|
5
|
+
* guarantee said at every act). Going DOWN is itself a catalog act signed by an
|
|
6
|
+
* enrolled phone over the record's bytes; a `normal` record without that
|
|
7
|
+
* signature reads as `max`. Going up is free.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { humanActOf } from "./human-act.js";
|
|
15
|
+
import { PHONE_INVITE } from "./phone-invite.js";
|
|
16
|
+
import { isPhoneEnrolled, readEnrolledKeys, requestPhoneSignature, verifySignedFiles } from "./phone-sign.js";
|
|
17
|
+
|
|
18
|
+
export const SECURITY_LEVEL_FILE = ".karajan/security-level.json";
|
|
19
|
+
// ADR 0018 (KJC-TSK-0997): `max` is the default now that CI verifies the signature
|
|
20
|
+
// (HUM-A) and the recovery code exists (HUM-B). The level that protects whoever
|
|
21
|
+
// does not know the risk exists; `kj security normal` lowers it, signed.
|
|
22
|
+
export const DEFAULT_LEVEL = "max";
|
|
23
|
+
|
|
24
|
+
const levelPath = (projectDir) => join(projectDir, SECURITY_LEVEL_FILE);
|
|
25
|
+
const recordBytes = (record) => `${JSON.stringify(record, null, 2)}\n`;
|
|
26
|
+
const unsigned = ({ level, since }) => ({ level, since });
|
|
27
|
+
|
|
28
|
+
/** What the phone signs when the level goes down: the record without its signature. */
|
|
29
|
+
export const signedFilesOf = (record) => [
|
|
30
|
+
{ file: SECURITY_LEVEL_FILE, sha256: createHash("sha256").update(recordBytes(unsigned(record))).digest("hex") },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The level in force. A record that cannot be trusted reads as `max` and says why.
|
|
35
|
+
* @returns {{level: "max"|"normal", source: "default"|"file"|"invalid", since?: string, signer?: string, reason?: string}}
|
|
36
|
+
*/
|
|
37
|
+
export function readSecurityLevel({ projectDir, home } = {}) {
|
|
38
|
+
const invalid = (reason) => ({ level: "max", source: "invalid", reason: `${SECURITY_LEVEL_FILE}: ${reason}` });
|
|
39
|
+
const path = levelPath(projectDir);
|
|
40
|
+
if (!existsSync(path)) return { level: DEFAULT_LEVEL, source: "default" };
|
|
41
|
+
let record;
|
|
42
|
+
try { record = JSON.parse(readFileSync(path, "utf8")); } catch (err) { return invalid(`no es JSON (${err.message})`); }
|
|
43
|
+
if (record?.level === "max") return { level: "max", source: "file", since: record.since };
|
|
44
|
+
if (record?.level !== "normal") return invalid("declara un nivel que no existe");
|
|
45
|
+
const keys = readEnrolledKeys({ projectDir, home });
|
|
46
|
+
const check = verifySignedFiles({ sig: record.signature, files: signedFilesOf(record), keys });
|
|
47
|
+
if (!check.ok) return invalid(`el nivel normal ${check.reason}; vale como máxima`);
|
|
48
|
+
return { level: "normal", source: "file", since: record.since, signer: record.signature.signer };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Up to `max`: no act, no signature; it also replaces a record that cannot be trusted. */
|
|
52
|
+
export function raiseSecurityLevel({ projectDir, home } = {}) {
|
|
53
|
+
const current = readSecurityLevel({ projectDir, home });
|
|
54
|
+
if (current.level === "max" && current.source !== "invalid") return { changed: false, level: current };
|
|
55
|
+
mkdirSync(dirname(levelPath(projectDir)), { recursive: true });
|
|
56
|
+
writeFileSync(levelPath(projectDir), recordBytes({ level: "max", since: new Date().toISOString() }), "utf8");
|
|
57
|
+
return { changed: true, level: readSecurityLevel({ projectDir, home }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const explain = (status) => ({
|
|
61
|
+
file: SECURITY_LEVEL_FILE, status, added: 3, removed: status === "modified" ? 3 : 0, truncated: false,
|
|
62
|
+
summary: "El nivel de seguridad de este proyecto baja de máxima a normal.",
|
|
63
|
+
diff: [
|
|
64
|
+
"Qué se va a hacer: los actos del catálogo (sellar el supervisor, aprobar reglas, enrolar un móvil) pasan a confirmarse",
|
|
65
|
+
"con las cuatro capas de ADR 0009 y un código tecleado; el móvil deja de firmarlos.",
|
|
66
|
+
"Por qué se pide tu firma: bajar el nivel es un acto del catálogo (ADR 0018); si no se firmara, el primero en bajarlo sería el agente.",
|
|
67
|
+
"Si lo hiciera otro: un registro de nivel normal sin la firma de un móvil del padrón no vale, y kj aplica la máxima.",
|
|
68
|
+
].join("\n"),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Down to `normal`: the catalog act, four layers and the phone's signature; without a phone it refuses. */
|
|
72
|
+
export async function lowerSecurityLevel({ projectDir, home, kjVersion, env, tty, deps = {}, logger = console }) {
|
|
73
|
+
const act = humanActOf("security-level-down");
|
|
74
|
+
act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
|
|
75
|
+
const current = readSecurityLevel({ projectDir, home });
|
|
76
|
+
if (current.level === "normal") return { changed: false, level: current };
|
|
77
|
+
const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
|
|
78
|
+
if (!phone.enrolled({ home })) throw new Error(`${act.label}: bajar el nivel se firma con el móvil y no hay ninguno enrolado. ${PHONE_INVITE}`);
|
|
79
|
+
act.confirm(deps.confirm);
|
|
80
|
+
const record = { level: "normal", since: new Date().toISOString() };
|
|
81
|
+
let branch = null;
|
|
82
|
+
try { branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir, encoding: "utf8" }).trim(); } catch { /* no repo */ }
|
|
83
|
+
const signed = await phone.request({
|
|
84
|
+
project: basename(projectDir), files: signedFilesOf(record), kjVersion, branch, logger,
|
|
85
|
+
changes: [explain(existsSync(levelPath(projectDir)) ? "modified" : "new")],
|
|
86
|
+
origin: `kj security normal (kj ${kjVersion})`, deps: { projectDir, home },
|
|
87
|
+
});
|
|
88
|
+
if (!signed.ok) throw new Error(`${act.label}: firma del móvil rechazada (${signed.reason}); el nivel no cambia`);
|
|
89
|
+
if (!signed.challenge || !signed.signer || !signed.signature) throw new Error(`${act.label}: el relé no devolvió un desafío firmado que kj pueda grabar; el nivel no cambia`);
|
|
90
|
+
const signature = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
|
|
91
|
+
mkdirSync(dirname(levelPath(projectDir)), { recursive: true });
|
|
92
|
+
writeFileSync(levelPath(projectDir), recordBytes({ ...record, signature }), "utf8");
|
|
93
|
+
return { changed: true, level: readSecurityLevel({ projectDir, home }) };
|
|
94
|
+
}
|
|
@@ -64,6 +64,28 @@ export const headIndex = (words, heads) => {
|
|
|
64
64
|
return k < 0 ? words.length : i + k;
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* KJC-BUG-0306: a simple command that runs `gh ... issue create`, read by words
|
|
69
|
+
* (quotes, escapes and newlines as the shell reads them), not by words anywhere.
|
|
70
|
+
* @param {string} cmd
|
|
71
|
+
*/
|
|
72
|
+
export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
|
|
73
|
+
const i = headIndex(words, ["gh"]);
|
|
74
|
+
if (words[i]?.split("/").at(-1) !== "gh") return false;
|
|
75
|
+
// The subcommand words, with every flag skipped together with a value that may
|
|
76
|
+
// follow it (--repo x, --hostname y): what is left is "issue", then "create".
|
|
77
|
+
const subcommand = [];
|
|
78
|
+
let afterFlag = false;
|
|
79
|
+
for (const w of words.slice(i + 1)) {
|
|
80
|
+
if (w.startsWith("-")) { afterFlag = !w.includes("="); continue; }
|
|
81
|
+
if (afterFlag && !["issue", "create"].includes(w)) { afterFlag = false; continue; }
|
|
82
|
+
afterFlag = false;
|
|
83
|
+
subcommand.push(w);
|
|
84
|
+
if (subcommand.length === 2) break;
|
|
85
|
+
}
|
|
86
|
+
return subcommand[0] === "issue" && subcommand[1] === "create";
|
|
87
|
+
});
|
|
88
|
+
|
|
67
89
|
// KJC-BUG-0286: a $ expands only when something expandable follows it ($x, ${x},
|
|
68
90
|
// $(cmd), $1, $@...). A lone "$ " in a grep pattern is a literal dollar sign.
|
|
69
91
|
const LIVE_EXPANSION = /\$(?=[A-Za-z0-9_{(@*#?$!-])|`/;
|
|
@@ -90,11 +112,16 @@ export const stripInertQuotes = (cmd) => {
|
|
|
90
112
|
};
|
|
91
113
|
|
|
92
114
|
// Options whose value is prose, never a path (gh, git, kj).
|
|
93
|
-
|
|
115
|
+
// KJC-BUG-0306: --error and --command of kj report-issue, --scope and --tests of kj hu add.
|
|
116
|
+
const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests"]);
|
|
94
117
|
const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
118
|
+
// KJC-BUG-0306: the title kj hu add and kj adr add take as their first argument, at the start of a segment.
|
|
119
|
+
const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
95
120
|
|
|
96
121
|
/** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
|
|
97
|
-
export const stripTextOptionValues = (cmd) => cmd
|
|
122
|
+
export const stripTextOptionValues = (cmd) => cmd
|
|
123
|
+
.replace(KJ_TITLE, (m, pre, head, val) => `${pre}${head}${val[0]}${val[0]}`)
|
|
124
|
+
.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
|
|
98
125
|
|
|
99
126
|
/**
|
|
100
127
|
* KJC-BUG-0245: the values of the named options, read by the shell reader, so a
|
|
@@ -467,13 +467,13 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
467
467
|
import console from "node:console";
|
|
468
468
|
import process from "node:process";
|
|
469
469
|
import { dirname, join, relative, resolve } from "node:path";
|
|
470
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
470
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
471
471
|
import { spawnSync } from "node:child_process";
|
|
472
472
|
import { homedir } from "node:os";
|
|
473
473
|
import { fileURLToPath } from "node:url";
|
|
474
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
474
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
475
475
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
476
|
-
import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
476
|
+
import { isGhIssueCreate, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
477
477
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
478
478
|
import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
|
|
479
479
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
@@ -495,8 +495,17 @@ process.stdin.on("end", () => {
|
|
|
495
495
|
try {
|
|
496
496
|
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
|
|
497
497
|
// KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
|
|
498
|
+
// KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
|
|
499
|
+
// gate's own words: kj handoff takes the why from here, never from the agent.
|
|
500
|
+
const said = [];
|
|
501
|
+
const errOrig = console.error.bind(console);
|
|
502
|
+
console.error = (m) => { said.push(String(m)); errOrig(m); };
|
|
498
503
|
process.on("exit", (code) => {
|
|
499
|
-
if (code
|
|
504
|
+
if (code !== 2) return;
|
|
505
|
+
errOrig("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
|
|
506
|
+
// Only what a handoff needs: the exact Bash command, or the path an edit aimed at; never file contents.
|
|
507
|
+
const what = tool === "Bash" ? { command: input.command } : { file_path: input.file_path || input.notebook_path || null };
|
|
508
|
+
try { appendFileSync(join(dirname(STATE), "denials.jsonl"), JSON.stringify({ at: new Date().toISOString(), sid, tool, input: what, why: said.join("\\n") }) + "\\n"); } catch { /* the deny stands without its record */ }
|
|
500
509
|
});
|
|
501
510
|
// KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
|
|
502
511
|
// sesion toca un fichero se anota si estaba limpio: solo entonces un
|
|
@@ -574,7 +583,9 @@ process.stdin.on("end", () => {
|
|
|
574
583
|
// (sanitiza y busca duplicados), nunca por gh issue create a mano.
|
|
575
584
|
{
|
|
576
585
|
const ghIssue = String(input.command || "");
|
|
577
|
-
|
|
586
|
+
// KJC-BUG-0306: the subcommand itself (gh, its flags, issue create), not three words
|
|
587
|
+
// anywhere: "gh pr create --title 'kj report-issue ...'" is not an issue.
|
|
588
|
+
if (tool === "Bash" && isGhIssueCreate(ghIssue)) {
|
|
578
589
|
// The target: --repo/-R, an inline or inherited GH_REPO, else the
|
|
579
590
|
// checkout's own origin, which is what gh takes (review catches).
|
|
580
591
|
const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
|
|
@@ -884,7 +895,7 @@ process.stdin.on("end", () => {
|
|
|
884
895
|
// KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
|
|
885
896
|
// of a text option (--title, -m...) is not a path.
|
|
886
897
|
if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
|
|
887
|
-
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
|
|
898
|
+
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion. Si es texto (titulo, descripcion, error) con comillas de codigo o dolar, ponlo entre comillas SIMPLES: entre dobles la shell lo ejecuta." + doc("cross-lane"));
|
|
888
899
|
process.exit(2);
|
|
889
900
|
}
|
|
890
901
|
const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
14
|
import { readIdentity } from "../identity/store.js";
|
|
15
15
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
16
|
+
import { signAct } from "./act-sign.js";
|
|
16
17
|
import { humanActOf } from "./human-act.js";
|
|
17
|
-
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
18
18
|
import { describeSupervisorChanges } from "./sign-changes.js";
|
|
19
19
|
|
|
20
20
|
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
@@ -120,33 +120,18 @@ export async function commitSupervisorRegeneration({
|
|
|
120
120
|
logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
|
|
121
121
|
return { committed: false, reason: "sin drift" };
|
|
122
122
|
}
|
|
123
|
-
// Capa 5 (KJC-TSK-0822 / PRP-0023
|
|
124
|
-
//
|
|
125
|
-
// files/hashes
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
kjVersion,
|
|
136
|
-
changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
|
|
137
|
-
branch,
|
|
138
|
-
origin: `kj harden ${kjVersion}`,
|
|
139
|
-
logger,
|
|
140
|
-
});
|
|
141
|
-
if (!signed.ok) {
|
|
142
|
-
throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
|
|
143
|
-
}
|
|
144
|
-
// KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
|
|
145
|
-
// re-verifique server-side que un humano (clave del padrón) aprobó.
|
|
146
|
-
if (signed.challenge && signed.signature) {
|
|
147
|
-
signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
|
|
148
|
-
}
|
|
149
|
-
}
|
|
123
|
+
// Capa 5 (KJC-TSK-0822 / PRP-0023, KJC-TSK-0996 / ADR 0018): the level applied
|
|
124
|
+
// by the catalog's mechanism. With a phone enrolled its signature over the SAME
|
|
125
|
+
// files/hashes of the provenance is mandatory, never nonce-only; at max with no
|
|
126
|
+
// phone the seal is refused. KJC-TSK-0992: the page says what is signed, file by
|
|
127
|
+
// file; KJC-TSK-0823: the block is recorded so CI re-verifies server-side.
|
|
128
|
+
let branch = null;
|
|
129
|
+
try { branch = run(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); } catch { /* unborn HEAD */ }
|
|
130
|
+
const { signature: signatureBlock } = await signAct("supervisor-seal", {
|
|
131
|
+
projectDir, kjVersion, branch, logger, deps,
|
|
132
|
+
files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
|
|
133
|
+
changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
|
|
134
|
+
});
|
|
150
135
|
const who = readIdentity(projectDir);
|
|
151
136
|
const provenance = {
|
|
152
137
|
kj_version: kjVersion,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1000 (HUM-B3, ADR 0018): CI judges every change of the roster of
|
|
3
|
+
* signing phones. When the diff touches .karajan/supervisor-signers.json, the
|
|
4
|
+
* roster of the base (the range's start, or HEAD for a staged check) and the
|
|
5
|
+
* one that lands (the checkout, or the staged blob) go through
|
|
6
|
+
* `verifyRosterChange`; a refusal is a deny violation with its reason, so that
|
|
7
|
+
* reinstalling kj on a machine never changes what CI demands.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { SIGNERS_FILE, verifyRosterChange } from "../harden/roster.js";
|
|
13
|
+
|
|
14
|
+
export const ROSTER_RULE = "defaults.roster.change";
|
|
15
|
+
|
|
16
|
+
const parse = (text) => { try { return JSON.parse(text); } catch { return {}; } };
|
|
17
|
+
// An object that is not a roster reads as an empty one: a base that is missing is
|
|
18
|
+
// a first roster; a landing one that is missing or broken loses every key.
|
|
19
|
+
const shown = async (run, spec) => { try { return parse(await run(["show", spec])); } catch { return null; } };
|
|
20
|
+
const local = (projectDir) => { try { return parse(readFileSync(join(projectDir, SIGNERS_FILE), "utf8")); } catch { return null; } };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{projectDir: string, files: string[], range: string|null, run: (args: string[]) => Promise<string>}} opts
|
|
24
|
+
* @returns {Promise<object|null>} the violation, or null when the roster is untouched or the change is backed
|
|
25
|
+
*/
|
|
26
|
+
export async function rosterViolation({ projectDir, files, range, run }) {
|
|
27
|
+
if (!files.includes(SIGNERS_FILE)) return null;
|
|
28
|
+
const baseRef = range ? range.split(/\.{2,3}/)[0] : "HEAD";
|
|
29
|
+
const before = await shown(run, `${baseRef}:${SIGNERS_FILE}`);
|
|
30
|
+
const after = range ? local(projectDir) : await shown(run, `:${SIGNERS_FILE}`);
|
|
31
|
+
const verdict = verifyRosterChange({ before, after });
|
|
32
|
+
if (verdict.ok) return null;
|
|
33
|
+
return { rule_id: ROSTER_RULE, enforcement: "deny", file: SIGNERS_FILE, reason: `el padrón de móviles cambia sin respaldo (ADR 0018): ${verdict.reason}` };
|
|
34
|
+
}
|
|
@@ -42,6 +42,8 @@ export const CONTRACT_BLOCK = [
|
|
|
42
42
|
// ADR 0016 (KJC-TSK-0947): the rules of the MD files compiled into gates are
|
|
43
43
|
// governance the whole team inherits, like the hooks.
|
|
44
44
|
"!.karajan/rules.yml",
|
|
45
|
+
// KJC-TSK-0995 (HUM-D, ADR 0018): the security level, signed when lowered, is the team's.
|
|
46
|
+
"!.karajan/security-level.json",
|
|
45
47
|
];
|
|
46
48
|
|
|
47
49
|
/**
|