karajan-code 4.26.0 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/cli/register-meta.js +12 -0
- package/src/commands/harden.js +22 -0
- package/src/commands/identity.js +13 -0
- package/src/commands/policy.js +9 -1
- package/src/commands/review-gate.js +12 -0
- package/src/harden/harden-engine.js +21 -1
- package/src/harden/hook-templates.js +20 -2
- package/src/harden/phone-sign.js +119 -0
- package/src/harden/sentinel-hooks.js +61 -0
- package/src/harden/supervisor-commit.js +169 -0
- package/src/harden/workflow-templates.js +9 -1
- package/src/policy/engine.js +3 -1
- package/src/policy/supervisor-verify.js +79 -0
- package/src/review/gate-gitignore.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"karajan-rag": "^1.2.0",
|
|
117
117
|
"knip": "^6.15.0",
|
|
118
118
|
"madge": "^8.0.0",
|
|
119
|
+
"qrcode-terminal": "0.12.0",
|
|
119
120
|
"sqlite-vec": "^0.1.9",
|
|
120
121
|
"valibot": "^1.4.1",
|
|
121
122
|
"web-tree-sitter": "^0.26.9",
|
package/src/cli/register-meta.js
CHANGED
|
@@ -148,6 +148,15 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
148
148
|
process.exitCode = await identityCommand({ action: "set", config, flags });
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
|
+
identityCmd
|
|
152
|
+
.command("enroll-phone")
|
|
153
|
+
.description("Enroll the phone's PUBLIC signing key (base64 of the raw 32-byte ed25519 key) for the supervisor seal")
|
|
154
|
+
.argument("<publicKeyBase64>", "raw ed25519 public key, base64 (shown by the phone app)")
|
|
155
|
+
.action(async (publicKeyBase64, flags) => {
|
|
156
|
+
await withConfig(pkgVersion, "identity-enroll-phone", flags, async ({ config }) => {
|
|
157
|
+
process.exitCode = await identityCommand({ action: "enroll-phone", config, flags: { ...flags, publicKeyBase64 } });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
151
160
|
|
|
152
161
|
// MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
|
|
153
162
|
// question, and the person lands inside a governed conversation.
|
|
@@ -783,10 +792,13 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
783
792
|
.option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
|
|
784
793
|
.option("--dry-run", "Show what would change without writing")
|
|
785
794
|
.option("--json", "Emit machine-readable JSON")
|
|
795
|
+
.option("--commit", "Versiona la regeneración del supervisor con procedencia sellada (acto humano, ADR 0009)")
|
|
786
796
|
.action(async (flags) => {
|
|
787
797
|
await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
|
|
788
798
|
await hardenCommand({
|
|
789
799
|
profile: flags.profile,
|
|
800
|
+
commitSupervisor: flags.commit === true,
|
|
801
|
+
kjVersion: pkgVersion,
|
|
790
802
|
config: flags.config !== false,
|
|
791
803
|
ci: flags.ci !== false,
|
|
792
804
|
guidelines: flags.guidelines !== false,
|
package/src/commands/harden.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveTestOnPush } from "../harden/test-on-push.js";
|
|
|
14
14
|
import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
|
|
15
15
|
import { interactiveHarden } from "../harden/interactive.js";
|
|
16
16
|
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
17
|
+
import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
|
|
17
18
|
import { installConfigsForRoots } from "../harden/config-engine.js";
|
|
18
19
|
import { installGuidelines } from "../harden/guidelines-engine.js";
|
|
19
20
|
import { commandsForLanguage } from "../harden/hook-commands.js";
|
|
@@ -88,6 +89,8 @@ export async function hardenCommand({
|
|
|
88
89
|
interactive = false,
|
|
89
90
|
only = [],
|
|
90
91
|
exclude = [],
|
|
92
|
+
commitSupervisor = false,
|
|
93
|
+
kjVersion = null,
|
|
91
94
|
logger = console,
|
|
92
95
|
} = {}) {
|
|
93
96
|
const repoCfg = readHardenConfig(projectDir);
|
|
@@ -199,5 +202,24 @@ export async function hardenCommand({
|
|
|
199
202
|
out.identity = id.declared ? id.identity : null;
|
|
200
203
|
if (id.pending) out.identityPending = id.pending;
|
|
201
204
|
}
|
|
205
|
+
// KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
|
|
206
|
+
// del supervisor con procedencia sellada. Acto humano — el guard vive en
|
|
207
|
+
// commitSupervisorRegeneration y rechaza sesiones de agente.
|
|
208
|
+
if (commitSupervisor && !dryRun) {
|
|
209
|
+
try {
|
|
210
|
+
out.supervisorCommit = await commitSupervisorRegeneration({
|
|
211
|
+
projectDir,
|
|
212
|
+
kjVersion: kjVersion ?? "unknown",
|
|
213
|
+
generation: result.generation ?? { profile },
|
|
214
|
+
logger,
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// Fallar ALTO (catch de codex): un --commit que no commitea no puede
|
|
218
|
+
// devolver ok — quien lo pidió debe verlo, no descubrirlo después.
|
|
219
|
+
logger.error?.(`kj harden: ${err.message}`);
|
|
220
|
+
out.supervisorCommit = { committed: false, reason: err.message };
|
|
221
|
+
out.ok = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
202
224
|
return out;
|
|
203
225
|
}
|
package/src/commands/identity.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* no session and no flags → exit 1.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { enrollPhone } from "../harden/phone-sign.js";
|
|
11
12
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
12
13
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
13
14
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -23,6 +24,18 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
23
24
|
git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
// KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
|
|
28
|
+
if (action === "enroll-phone") {
|
|
29
|
+
try {
|
|
30
|
+
enrollPhone(flags.publicKeyBase64, { home: deps.home });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
log(`kj identity enroll-phone: ${err.message}`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
log("Móvil enrolado: la clave pública quedó en ~/.karajan/supervisor-phone.json. A partir de ahora, sellar el supervisor pedirá la firma de tu móvil.");
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
if (action === "set") {
|
|
27
40
|
const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
|
|
28
41
|
if (!candidate.gh_user || !candidate.git_email) {
|
package/src/commands/policy.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
+
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
15
16
|
import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
17
|
import { buildPolicyReport } from "../policy/report.js";
|
|
17
18
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
@@ -231,7 +232,14 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
231
232
|
// --strict (PL-C): con --strict, una violación enforcement=deny devuelve
|
|
232
233
|
// exit 2 nombrando la regla — el contrato merge-blocking del tier C.
|
|
233
234
|
const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
|
|
234
|
-
|
|
235
|
+
let violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
|
|
236
|
+
// ADR 0009 (KJC-BUG-0161): en CI la misma exención estructural que en la
|
|
237
|
+
// review — supervisor respaldado por provenance sellada + render canónico.
|
|
238
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations });
|
|
239
|
+
if (sup.lifted > 0) {
|
|
240
|
+
logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
|
|
241
|
+
violations = sup.violations;
|
|
242
|
+
}
|
|
235
243
|
const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
|
|
236
244
|
if (flags.json) {
|
|
237
245
|
logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
|
|
@@ -15,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
|
+
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
18
19
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
19
20
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
20
21
|
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
@@ -262,6 +263,17 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
262
263
|
recordException: (entry) => recordPolicyException({ projectDir, entry }),
|
|
263
264
|
standingExceptions: std.standing,
|
|
264
265
|
});
|
|
266
|
+
// ADR 0009 (KJC-BUG-0161): la exención ESTRUCTURAL del supervisor — un
|
|
267
|
+
// diff de .karajan/hooks respaldado por la provenance sellada Y el render
|
|
268
|
+
// canónico no es una edición: es la regeneración de kj harden --commit.
|
|
269
|
+
{
|
|
270
|
+
const res = liftSealedSupervisorViolations({ projectDir, violations: gate.denials });
|
|
271
|
+
if (res.lifted > 0) {
|
|
272
|
+
console.log(`✓ policy: ${res.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009) — regeneración, no edición`);
|
|
273
|
+
gate.denials = res.violations;
|
|
274
|
+
gate.ok = gate.denials.length === 0 && !gate.invalid;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
265
277
|
const at = (v) => (v.file ? ` (${v.file})` : "");
|
|
266
278
|
for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
|
|
267
279
|
for (const e of gate.exempted) console.log(formatExempted(e));
|
|
@@ -22,6 +22,24 @@ export function isGitRepo(projectDir) {
|
|
|
22
22
|
return existsSync(join(projectDir, ".git"));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* KJC-BUG-0161 / ADR 0009: el contenido CANÓNICO de un hook para una
|
|
27
|
+
* generación dada — exactamente lo que installHooks escribiría partiendo
|
|
28
|
+
* de cero. La verificación estructural del supervisor recomputa esto y
|
|
29
|
+
* compara por hash: un fichero con una sola coma manual ya no coincide.
|
|
30
|
+
*/
|
|
31
|
+
export function renderCanonicalHook(hook, generation = {}) {
|
|
32
|
+
const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
|
|
33
|
+
const { content } = upsertManagedBlock({
|
|
34
|
+
source: HOOK_PREAMBLE,
|
|
35
|
+
blockId: `hook:${hook}`,
|
|
36
|
+
version: BLOCK_VERSION,
|
|
37
|
+
body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
|
|
38
|
+
style: "hash",
|
|
39
|
+
});
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
/**
|
|
26
44
|
* Install (or refresh) the profile's hooks under `.karajan/hooks/` and point
|
|
27
45
|
* `core.hooksPath` there. Idempotent. `dryRun` computes actions without
|
|
@@ -76,5 +94,7 @@ export async function installHooks({
|
|
|
76
94
|
await runCommand("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: projectDir });
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
|
|
97
|
+
// KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
|
|
98
|
+
// provenance necesita registrar para que CI pueda recomputar y comparar.
|
|
99
|
+
return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
|
|
80
100
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
11
13
|
const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
|
|
12
14
|
const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
|
|
13
15
|
|
|
@@ -27,10 +29,18 @@ export const PROFILE_HOOKS = {
|
|
|
27
29
|
// guarded, so machines without it are unaffected.
|
|
28
30
|
function chainToGlobal(hook, globalHooksDir) {
|
|
29
31
|
if (!globalHooksDir) return [];
|
|
32
|
+
// KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
|
|
33
|
+
// absolute home path — resolve through $HOME at runtime so the same
|
|
34
|
+
// generated content is valid on every machine and its provenance stays
|
|
35
|
+
// verifiable (and no local filesystem layout leaks into a public repo).
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
|
|
38
|
+
? `$HOME${globalHooksDir.slice(home.length)}`
|
|
39
|
+
: globalHooksDir;
|
|
30
40
|
return [
|
|
31
41
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
32
|
-
`if [ -x "${
|
|
33
|
-
` "${
|
|
42
|
+
`if [ -x "${dir}/${hook}" ]; then`,
|
|
43
|
+
` "${dir}/${hook}" "$@"; rc=$?`,
|
|
34
44
|
' [ "$rc" -eq 0 ] || exit "$rc"',
|
|
35
45
|
"fi",
|
|
36
46
|
];
|
|
@@ -99,6 +109,14 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
99
109
|
if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
|
|
100
110
|
if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
|
|
101
111
|
lines.push(
|
|
112
|
+
"# KJC-BUG-0164: attribution is surface EVERYWHERE — the staged diff's",
|
|
113
|
+
"# ADDED lines (changelog, docs, code comments) are scanned too, with",
|
|
114
|
+
"# the same precise pattern (tool MENTIONS stay legal; attribution not).",
|
|
115
|
+
"# The guard's own definition files are excluded by literal path — they",
|
|
116
|
+
"# CONTAIN the pattern; excluding a path that does not exist is harmless.",
|
|
117
|
+
`if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
|
|
118
|
+
" echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
|
|
119
|
+
"fi",
|
|
102
120
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
103
121
|
"# a staged diff only enters with a recorded cross-AI approved verdict.",
|
|
104
122
|
"if [ -f .karajan/review-gate ]; then",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0822 (PRP-0023) — capa 5 del sello del supervisor: firma asimétrica
|
|
3
|
+
* desde el móvil. La clave privada vive SOLO en el teléfono; aquí se enrola su
|
|
4
|
+
* clave PÚBLICA (~/.karajan/supervisor-phone.json) y se verifica ed25519
|
|
5
|
+
* contra ella — el transporte (Firestore, PR siguiente) nunca es la verdad.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import qrcode from "qrcode-terminal";
|
|
13
|
+
|
|
14
|
+
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
15
|
+
// La config del relé (apiKey de cliente Firestore — pública por diseño, la
|
|
16
|
+
// misma que la página firmante lleva incrustada) NO viaja en el tarball: el
|
|
17
|
+
// escáner de privacidad del pack deniega claves google con razón general, y
|
|
18
|
+
// servirla desde la landing la hace además rotable sin release.
|
|
19
|
+
const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
|
|
20
|
+
let relayCache = null;
|
|
21
|
+
async function relayConfig(fetchFn) {
|
|
22
|
+
if (relayCache) return relayCache;
|
|
23
|
+
const res = await fetchFn(RELAY_CONFIG_URL);
|
|
24
|
+
if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
|
|
25
|
+
const cfg = await res.json();
|
|
26
|
+
if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
|
|
27
|
+
relayCache = {
|
|
28
|
+
apiKey: cfg.apiKey,
|
|
29
|
+
url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
|
|
30
|
+
};
|
|
31
|
+
return relayCache;
|
|
32
|
+
}
|
|
33
|
+
const SIGN_PAGE = "https://karajancode.com/sign";
|
|
34
|
+
const POLL_INTERVAL_MS = 2000;
|
|
35
|
+
const TTL_MS = 120000;
|
|
36
|
+
|
|
37
|
+
const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
|
|
38
|
+
|
|
39
|
+
export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
|
|
40
|
+
|
|
41
|
+
export const readEnrolledKey = ({ home } = {}) =>
|
|
42
|
+
JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
|
|
43
|
+
|
|
44
|
+
/** Valida y persiste la clave pública del móvil (base64 del raw de 32 bytes). */
|
|
45
|
+
export function enrollPhone(publicKeyBase64, { home } = {}) {
|
|
46
|
+
const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
|
|
47
|
+
if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
|
|
48
|
+
throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
|
|
51
|
+
const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
|
|
52
|
+
writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios. */
|
|
56
|
+
export function canonicalPayload({ cid, nonce, project, files }) {
|
|
57
|
+
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
58
|
+
return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Verifica la firma (base64) del payload contra la clave pública raw (base64). */
|
|
62
|
+
export function verifyPhoneSignature({ payload, signature, publicKey }) {
|
|
63
|
+
const der = Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(publicKey, "base64")]);
|
|
64
|
+
const key = createPublicKey({ key: der, format: "der", type: "spki" });
|
|
65
|
+
return verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Publica la petición en Firestore, enseña QR + URL, pollea el doc y verifica
|
|
70
|
+
* la firma. Sin fallbacks silenciosos: red caída o HTTP no-ok ⇒ throw. Firma
|
|
71
|
+
* mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
|
|
72
|
+
* @returns {Promise<{ok: boolean, reason?: string}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function requestPhoneSignature({ project, files, kjVersion, logger = console, deps = {} }) {
|
|
75
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
76
|
+
const now = deps.now ?? Date.now;
|
|
77
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
78
|
+
const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
|
|
79
|
+
const cid = randomBytes(16).toString("hex");
|
|
80
|
+
const nonce = randomBytes(16).toString("hex");
|
|
81
|
+
const body = {
|
|
82
|
+
fields: {
|
|
83
|
+
nonce: { stringValue: nonce },
|
|
84
|
+
project: { stringValue: project },
|
|
85
|
+
kj_version: { stringValue: kjVersion },
|
|
86
|
+
state: { stringValue: "pending" },
|
|
87
|
+
createdAt: { timestampValue: new Date().toISOString() },
|
|
88
|
+
files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const relay = await relayConfig(fetchFn);
|
|
92
|
+
const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
|
|
93
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
94
|
+
});
|
|
95
|
+
if (!created.ok) {
|
|
96
|
+
throw new Error(`phone-sign: no se pudo publicar la petición de firma (HTTP ${created.status}) — sin red no hay sello, no se degrada`);
|
|
97
|
+
}
|
|
98
|
+
const signUrl = `${SIGN_PAGE}?c=${cid}`;
|
|
99
|
+
drawQr(signUrl);
|
|
100
|
+
logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
|
|
101
|
+
const deadline = now() + TTL_MS;
|
|
102
|
+
while (now() < deadline) {
|
|
103
|
+
const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
|
|
104
|
+
if (!res.ok) throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`);
|
|
105
|
+
const fields = (await res.json()).fields ?? {};
|
|
106
|
+
if (fields.state?.stringValue === "signed") {
|
|
107
|
+
const enrolled = readEnrolledKey({ home: deps.home });
|
|
108
|
+
if (fields.publicKey?.stringValue !== enrolled) {
|
|
109
|
+
return { ok: false, reason: "la publicKey del doc no coincide con la enrolada — el doc es transporte, la verdad es la enrolada" };
|
|
110
|
+
}
|
|
111
|
+
const payload = canonicalPayload({ cid, nonce, project, files });
|
|
112
|
+
const good = verifyPhoneSignature({ payload, signature: fields.signature?.stringValue ?? "", publicKey: enrolled });
|
|
113
|
+
return good ? { ok: true } : { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
|
|
114
|
+
}
|
|
115
|
+
logger.info?.("phone-sign: esperando la firma del móvil…");
|
|
116
|
+
await sleep(POLL_INTERVAL_MS);
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, reason: "caducado" };
|
|
119
|
+
}
|
|
@@ -371,6 +371,58 @@ process.stdin.on("end", () => {
|
|
|
371
371
|
console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
|
|
372
372
|
process.exit(2);
|
|
373
373
|
}
|
|
374
|
+
// KJC-BUG-0164: todo gh que PUBLICA texto (pr/issue/release, create/edit/
|
|
375
|
+
// comment/review) se escanea contra el patron de ATRIBUCION a IA antes de
|
|
376
|
+
// salir — comando inline Y ficheros --body-file/--notes-file. Regla
|
|
377
|
+
// determinista del proyecto: prohibido, sin escape. Un fichero ilegible
|
|
378
|
+
// tampoco publica (fail closed).
|
|
379
|
+
{
|
|
380
|
+
// Regex SIN backslashes a proposito (clases de caracteres, emoji
|
|
381
|
+
// literal): asi la plantilla y el fichero generado son identicos y no
|
|
382
|
+
// hay doble-escape que leer mal.
|
|
383
|
+
const ghCmd = String(input.command || "");
|
|
384
|
+
if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
|
|
385
|
+
const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
|
|
386
|
+
let corpus = ghCmd;
|
|
387
|
+
for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
|
|
388
|
+
const bodyPath = m[2] || m[3] || m[4];
|
|
389
|
+
try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
|
|
390
|
+
console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
|
|
391
|
+
process.exit(2);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (attrib.test(corpus)) {
|
|
395
|
+
console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
|
|
396
|
+
process.exit(2);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
|
|
401
|
+
// — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
|
|
402
|
+
// solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
|
|
403
|
+
{
|
|
404
|
+
const enrollCmd = String(input.command || "");
|
|
405
|
+
const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
|
|
406
|
+
const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
|
|
407
|
+
if ((tool === "Bash" && namesEnroll) || writesEnroll) {
|
|
408
|
+
console.error("karajan sentinel: el enrolamiento del movil (capa 5) es un acto humano — pideselo a tu usuario; supervisor-phone.json no se toca desde la sesion." + doc("supervisor"));
|
|
409
|
+
process.exit(2);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
413
|
+
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
414
|
+
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
415
|
+
// (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
|
|
416
|
+
// ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
|
|
417
|
+
// arriba: un env var lo pone tambien un agente — el supervisor no se
|
|
418
|
+
// apaga con interruptores al alcance del supervisado.
|
|
419
|
+
{
|
|
420
|
+
const kjCmd = String(input.command || "");
|
|
421
|
+
if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
|
|
422
|
+
console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
|
|
423
|
+
process.exit(2);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
374
426
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
375
427
|
// KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
|
|
376
428
|
// (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
|
|
@@ -564,6 +616,15 @@ process.stdin.on("end", () => {
|
|
|
564
616
|
}
|
|
565
617
|
if (tool === "Bash") {
|
|
566
618
|
const cmd = String(input.command || "");
|
|
619
|
+
// KJC-BUG-0166: una sesion intento EN BUCLE retirar el ARBOL PRINCIPAL
|
|
620
|
+
// con git worktree remove y KJ_ALLOW_CROSS_LANE puesto. Retirar un
|
|
621
|
+
// worktree es destruccion de la estructura del repo — jamas trabajo de
|
|
622
|
+
// una sesion, y el escape de cruce NO puede ampararlo. Deny sin escape
|
|
623
|
+
// (el cauce sancionado es kj worktree done, que corre dentro de kj).
|
|
624
|
+
if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(remove|prune)/.test(cmd)) {
|
|
625
|
+
console.error("karajan sentinel: git worktree remove/prune no se ejecuta desde una sesion — destruccion de estructura del repo, sin escape; usa kj worktree done." + doc("cross-lane"));
|
|
626
|
+
process.exit(2);
|
|
627
|
+
}
|
|
567
628
|
// Deny-unless-known-read-only: un allowlist de verbos mutadores era
|
|
568
629
|
// esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
|
|
569
630
|
// encadenar ni redirigir) puede nombrar otro carril; leer carriles es
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0161 / ADR 0009 (opción A) — `kj harden --commit`, el cauce
|
|
3
|
+
* sancionado del supervisor: ACTO HUMANO que versiona la regeneración con
|
|
4
|
+
* provenance trackeada (versión, parámetros, sha256), sello en el acta y
|
|
5
|
+
* commit quirúrgico. El --no-verify es a conciencia: lo que salta en local
|
|
6
|
+
* lo re-verifica CI contra la provenance (pieza 3).
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
|
+
import { readIdentity } from "../identity/store.js";
|
|
15
|
+
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
16
|
+
|
|
17
|
+
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
18
|
+
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
19
|
+
|
|
20
|
+
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
21
|
+
|
|
22
|
+
// Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
|
|
23
|
+
// isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
|
|
24
|
+
// de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
|
|
25
|
+
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
26
|
+
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
|
|
27
|
+
const read = readProc || ((p) => {
|
|
28
|
+
const stat = readFileSync(`/proc/${p}/stat`, "utf8");
|
|
29
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
30
|
+
let cmd = "";
|
|
31
|
+
try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
|
|
32
|
+
return { ppid, cmd };
|
|
33
|
+
});
|
|
34
|
+
let cur = pid;
|
|
35
|
+
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
36
|
+
let info;
|
|
37
|
+
try { info = read(cur); } catch { return { agent: false, unknown: true }; }
|
|
38
|
+
if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
|
|
39
|
+
if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
|
|
40
|
+
cur = info.ppid;
|
|
41
|
+
}
|
|
42
|
+
return { agent: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
|
|
46
|
+
// atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
|
|
47
|
+
function defaultConfirm(nonce) {
|
|
48
|
+
process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
|
|
49
|
+
try {
|
|
50
|
+
const buf = Buffer.alloc(64);
|
|
51
|
+
const fd = openSync("/dev/tty", "r");
|
|
52
|
+
const n = readSync(fd, buf, 0, 64);
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
return buf.toString("utf8", 0, n).trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
61
|
+
export function supervisorDrift({ projectDir, gitFn }) {
|
|
62
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
63
|
+
// Un rename sale como "R old -> new" (catch de codex): ambas rutas son
|
|
64
|
+
// drift — la vieja se versiona como borrado y la nueva con su hash.
|
|
65
|
+
return run(["status", "--porcelain", "--", ".karajan/hooks"])
|
|
66
|
+
.split("\n")
|
|
67
|
+
.flatMap((l) => l.slice(3).trim().split(" -> "))
|
|
68
|
+
.filter((f) => f.startsWith(HOOKS_PREFIX));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @returns {Promise<{committed: boolean, reason?: string, files?: object[]}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function commitSupervisorRegeneration({
|
|
75
|
+
projectDir,
|
|
76
|
+
kjVersion,
|
|
77
|
+
generation,
|
|
78
|
+
logger = console,
|
|
79
|
+
gitFn = null,
|
|
80
|
+
env = process.env,
|
|
81
|
+
tty = process.stdout.isTTY,
|
|
82
|
+
deps = {},
|
|
83
|
+
}) {
|
|
84
|
+
// El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
|
|
85
|
+
if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const anc = agentAncestry(deps.ancestry ?? {});
|
|
91
|
+
if (anc.agent) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
97
|
+
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
98
|
+
// Un alimentador ciego no conoce el código; automatizar su lectura exige
|
|
99
|
+
// un driver expect — premeditación inequívoca, no un descuido.
|
|
100
|
+
const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
|
|
101
|
+
const answer = (deps.confirm ?? defaultConfirm)(nonce);
|
|
102
|
+
if (answer !== nonce) {
|
|
103
|
+
throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
104
|
+
}
|
|
105
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
106
|
+
const drift = supervisorDrift({ projectDir, gitFn: run });
|
|
107
|
+
// La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
|
|
108
|
+
// en el primer estreno real: un sello parcial pisaba al anterior y dejaba
|
|
109
|
+
// ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
|
|
110
|
+
const current = readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f).sort();
|
|
111
|
+
const hashed = [
|
|
112
|
+
...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
|
|
113
|
+
...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
|
|
114
|
+
];
|
|
115
|
+
let previous = null;
|
|
116
|
+
try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }
|
|
117
|
+
const covered = JSON.stringify(previous?.files ?? null) === JSON.stringify(hashed);
|
|
118
|
+
if (drift.length === 0 && covered) {
|
|
119
|
+
logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
|
|
120
|
+
return { committed: false, reason: "sin drift" };
|
|
121
|
+
}
|
|
122
|
+
// Capa 5 (KJC-TSK-0822 / PRP-0023): con móvil enrolado la firma asimétrica
|
|
123
|
+
// es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
|
|
124
|
+
// files/hashes de la provenance (normalizados al par {file, sha256}).
|
|
125
|
+
const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
|
|
126
|
+
if (phone.enrolled({})) {
|
|
127
|
+
const signed = await phone.request({
|
|
128
|
+
project: basename(projectDir),
|
|
129
|
+
files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
|
|
130
|
+
kjVersion,
|
|
131
|
+
logger,
|
|
132
|
+
});
|
|
133
|
+
if (!signed.ok) {
|
|
134
|
+
throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const who = readIdentity(projectDir);
|
|
138
|
+
const provenance = {
|
|
139
|
+
kj_version: kjVersion,
|
|
140
|
+
generated_at: new Date().toISOString(),
|
|
141
|
+
generation,
|
|
142
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
143
|
+
files: hashed,
|
|
144
|
+
};
|
|
145
|
+
writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
|
146
|
+
recordGateDecision(projectDir, {
|
|
147
|
+
decision: "exempt",
|
|
148
|
+
chokepoint: "supervisor",
|
|
149
|
+
kind: "supervisor-regeneration",
|
|
150
|
+
kj_version: kjVersion,
|
|
151
|
+
files: hashed,
|
|
152
|
+
who: provenance.who,
|
|
153
|
+
});
|
|
154
|
+
// Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
|
|
155
|
+
// sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
|
|
156
|
+
// supervisor versiona altas, cambios, borrados y renombrados por igual.
|
|
157
|
+
run(["add", "-A", "--", PROVENANCE_FILE, ".karajan/hooks"]);
|
|
158
|
+
run([
|
|
159
|
+
"commit",
|
|
160
|
+
"--no-verify",
|
|
161
|
+
"-m",
|
|
162
|
+
`chore(harden): supervisor regenerated and sealed by kj harden v${kjVersion}`,
|
|
163
|
+
"--",
|
|
164
|
+
PROVENANCE_FILE,
|
|
165
|
+
".karajan/hooks",
|
|
166
|
+
]);
|
|
167
|
+
logger.info?.(`harden --commit: ${hashed.length} fichero(s) de supervisor versionados con procedencia sellada`);
|
|
168
|
+
return { committed: true, files: hashed };
|
|
169
|
+
}
|
|
@@ -36,9 +36,14 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
|
36
36
|
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
37
37
|
" with:",
|
|
38
38
|
" fetch-depth: 0",
|
|
39
|
-
" - name: Scan commit messages for AI attribution",
|
|
39
|
+
" - name: Scan commit messages and PR body for AI attribution",
|
|
40
40
|
" env:",
|
|
41
41
|
" BASE_REF: ${{ github.base_ref }}",
|
|
42
|
+
// KJC-BUG-0164: el cuerpo de la PR tambien es superficie — 15 PRs entraron
|
|
43
|
+
// con pie de atribucion porque solo se escaneaban commits. Via env, jamas
|
|
44
|
+
// interpolado en el script (el cuerpo es texto no confiable).
|
|
45
|
+
" PR_BODY: ${{ github.event.pull_request.body }}",
|
|
46
|
+
" PR_TITLE: ${{ github.event.pull_request.title }}",
|
|
42
47
|
" run: |",
|
|
43
48
|
' if ! msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"); then',
|
|
44
49
|
' echo "::error::cannot resolve origin/${BASE_REF}...HEAD — the scan did not run"; exit 1',
|
|
@@ -48,6 +53,9 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
|
48
53
|
' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
|
|
49
54
|
' echo "::error::AI attribution detected in commit messages"; exit 1',
|
|
50
55
|
" fi",
|
|
56
|
+
' if printf "%s\\n%s" "$PR_BODY" "$PR_TITLE" | grep -qiE "$pat|generated with \\[?claude|🤖"; then',
|
|
57
|
+
' echo "::error::AI attribution detected in the PR body or title"; exit 1',
|
|
58
|
+
" fi",
|
|
51
59
|
' echo "AI attribution scan: clean"',
|
|
52
60
|
].join("\n");
|
|
53
61
|
|
package/src/policy/engine.js
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
export const SUPERVISOR_DEFAULTS = [
|
|
26
26
|
{
|
|
27
27
|
id: "defaults.supervisor.write",
|
|
28
|
-
|
|
28
|
+
// ADR 0009: la provenance del supervisor es superficie de supervisor —
|
|
29
|
+
// un agente no la escribe; solo kj harden --commit (humano) la produce.
|
|
30
|
+
pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\/|\.karajan\/supervisor-provenance\.json\b/,
|
|
29
31
|
message: "nombra ficheros del supervisor — solo el humano los modifica, fuera de la sesión",
|
|
30
32
|
},
|
|
31
33
|
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0161 / ADR 0009, pieza 3 — exención ESTRUCTURAL del supervisor:
|
|
3
|
+
* un fichero de .karajan/hooks solo deja de ser violación si su contenido
|
|
4
|
+
* coincide (sha256) con la provenance sellada Y con el render canónico
|
|
5
|
+
* recomputado. No confía: recomputa. Una coma manual sigue denegada.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve, sep } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { renderCanonicalHook } from "../harden/harden-engine.js";
|
|
12
|
+
import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
13
|
+
|
|
14
|
+
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
15
|
+
// globalHooksDir es el único texto libre que se interpola en el render:
|
|
16
|
+
// vocabulario cerrado de ruta o la provenance no verifica NADA.
|
|
17
|
+
const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
|
|
18
|
+
|
|
19
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
|
|
23
|
+
* supervisor cuyo estado actual está respaldado por la provenance sellada.
|
|
24
|
+
*/
|
|
25
|
+
export function verifiedSupervisorFiles({ projectDir }) {
|
|
26
|
+
let prov;
|
|
27
|
+
try {
|
|
28
|
+
prov = JSON.parse(readFileSync(resolve(projectDir, PROVENANCE_FILE), "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return { files: new Set(), reason: "sin provenance" };
|
|
31
|
+
}
|
|
32
|
+
const gen = prov?.generation ?? {};
|
|
33
|
+
if (gen.globalHooksDir != null && !SAFE_DIR.test(gen.globalHooksDir)) {
|
|
34
|
+
return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
|
|
35
|
+
}
|
|
36
|
+
const ok = new Set();
|
|
37
|
+
const entries = Array.isArray(prov?.files) ? prov.files : [];
|
|
38
|
+
const hooksRoot = resolve(projectDir, HOOKS_PREFIX);
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (typeof entry?.file !== "string" || !entry.file.startsWith(HOOKS_PREFIX)) continue;
|
|
41
|
+
// Contención por resolución, no por prefijo textual (catch de codex).
|
|
42
|
+
const abs = resolve(projectDir, entry.file);
|
|
43
|
+
if (!abs.startsWith(hooksRoot + sep)) continue;
|
|
44
|
+
let st = null;
|
|
45
|
+
try { st = lstatSync(abs); } catch { /* no hay objeto en la ruta */ }
|
|
46
|
+
if (entry.deleted === true) {
|
|
47
|
+
if (st === null) ok.add(entry.file);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (typeof entry.sha256 !== "string" || !st?.isFile()) continue;
|
|
51
|
+
if (sha256(readFileSync(abs)) !== entry.sha256) continue;
|
|
52
|
+
let canonical;
|
|
53
|
+
try {
|
|
54
|
+
canonical = renderCanonicalHook(entry.file.slice(HOOKS_PREFIX.length), gen);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
|
|
59
|
+
}
|
|
60
|
+
// `complete` = CADA entrada verificó: solo entonces la provenance se
|
|
61
|
+
// describe con honestidad y su propio diff puede alzarse.
|
|
62
|
+
const complete = entries.length > 0 && entries.every((e) => typeof e?.file === "string" && ok.has(e.file));
|
|
63
|
+
return { files: ok, complete };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Filtra violaciones de supervisor respaldadas — {violations, lifted}. */
|
|
67
|
+
export function liftSealedSupervisorViolations({ projectDir, violations }) {
|
|
68
|
+
const RULE = "defaults.supervisor.write";
|
|
69
|
+
if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
|
|
70
|
+
const sealed = verifiedSupervisorFiles({ projectDir });
|
|
71
|
+
// La violación sobre la PROPIA provenance solo se alza si la provenance
|
|
72
|
+
// entera verificó — a medias no describe la verdad y sigue denegada.
|
|
73
|
+
const liftable = (v) =>
|
|
74
|
+
v.rule_id === RULE
|
|
75
|
+
&& v.file
|
|
76
|
+
&& (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
|
|
77
|
+
const kept = violations.filter((v) => !liftable(v));
|
|
78
|
+
return { violations: kept, lifted: violations.length - kept.length };
|
|
79
|
+
}
|
|
@@ -25,7 +25,11 @@ export const CONTRACT_BLOCK = [
|
|
|
25
25
|
"!.karajan/hooks/commit-msg",
|
|
26
26
|
"!.karajan/hooks/pre-push",
|
|
27
27
|
"!.karajan/hooks/post-merge",
|
|
28
|
+
"!.karajan/hooks/prepare-commit-msg",
|
|
28
29
|
"!.karajan/adrs/",
|
|
30
|
+
// ADR 0009 (KJC-BUG-0161): la procedencia del supervisor VIAJA con el
|
|
31
|
+
// repo — es lo que CI verifica. Cazado en el primer harden --commit real.
|
|
32
|
+
"!.karajan/supervisor-provenance.json",
|
|
29
33
|
];
|
|
30
34
|
|
|
31
35
|
/**
|