karajan-code 4.65.0 → 4.65.1
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/packages/hu-board/src/preflight.js +20 -5
- package/src/checks/repo-state.js +24 -1
- package/src/harden/branch-protection.js +21 -5
- package/src/harden/supervisor-commit.js +10 -3
- package/src/harden/workflow-templates.js +11 -1
- package/src/hu/team-board-mirror.js +9 -3
- package/src/policy/engine.js +2 -1
- package/src/policy/supervisor-verify.js +2 -2
package/package.json
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
import { execSync } from 'node:child_process';
|
|
31
31
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
32
|
-
import { join } from 'node:path';
|
|
32
|
+
import { join, resolve } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
33
34
|
import { getHuBoardPlansDirs } from './db.js';
|
|
34
35
|
|
|
35
36
|
function runQuiet(cmd, opts = {}) {
|
|
@@ -71,10 +72,24 @@ function checkGitRemote(projectDir) {
|
|
|
71
72
|
blocking: false
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// KJC-BUG-0398: origin is the remote that matters, not the first alphabetically; a remote
|
|
76
|
+
// whose URL is a local path that no longer exists is garbage, named with its removal.
|
|
77
|
+
// `git remote -v` is "<name>\t<url> (fetch|push)": a URL may hold spaces, and fetch and push may differ.
|
|
78
|
+
const entries = r.out.split('\n').map((line) => /^(\S+)\t(.+) \((?:fetch|push)\)$/.exec(line)).filter(Boolean).map(([, name, url]) => ({ name, url }));
|
|
79
|
+
const shown = (entries.find((e) => e.name === 'origin') ?? entries[0])?.url ?? '(unknown)';
|
|
80
|
+
// A local remote is an absolute or ./ ../ path (resolved from the project) or a file:// URL.
|
|
81
|
+
const localPath = (url) => {
|
|
82
|
+
if (url.startsWith('file://')) {
|
|
83
|
+
try { return fileURLToPath(url); } catch { return null; } // host-qualified or malformed: not judged
|
|
84
|
+
}
|
|
85
|
+
return /^(\/|\.\.?\/)/.test(url) ? resolve(projectDir, url) : null;
|
|
86
|
+
};
|
|
87
|
+
const dead = [...new Set(entries.filter(({ url }) => localPath(url) && !existsSync(localPath(url))).map((e) => e.name))];
|
|
88
|
+
if (dead.length === 0) return { id: 'git_remote', label: 'Repositorio remoto', status: 'ok', detail: shown, blocking: false };
|
|
89
|
+
return {
|
|
90
|
+
id: 'git_remote', label: 'Repositorio remoto', status: 'warn', detail: shown, blocking: false,
|
|
91
|
+
consequence: `Remotos que apuntan a carpetas que ya no existen: ${dead.join(', ')}. No sirven y confunden; bórralos con: ${dead.map((n) => `git remote remove -- '${n.replaceAll("'", "'\\''")}'`).join(' && ')}`,
|
|
92
|
+
};
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
function checkGitBranch(projectDir) {
|
package/src/checks/repo-state.js
CHANGED
|
@@ -19,10 +19,13 @@
|
|
|
19
19
|
|
|
20
20
|
import { execFileSync } from "node:child_process";
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
|
-
import { join } from "node:path";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
23
24
|
|
|
24
25
|
const STRATEGY_MANUAL = "manual";
|
|
25
26
|
|
|
27
|
+
// KJC-BUG-0398: a remote's name is free text; quoted, it can never be read as a command.
|
|
28
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
26
29
|
const git = (projectDir, args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
27
30
|
const gitOk = (projectDir, args) => { try { git(projectDir, args); return true; } catch { return false; } };
|
|
28
31
|
|
|
@@ -77,6 +80,26 @@ export function createRepoStateCheck({ projectDir }) {
|
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// KJC-BUG-0398: a remote whose URL is a local folder that no longer exists is garbage
|
|
84
|
+
// (four /tmp imports lived two months here unnoticed, and the board showed one as the remote).
|
|
85
|
+
// `git remote -v` is "<name>\t<url> (fetch|push)": a URL may hold spaces, and fetch and push may differ.
|
|
86
|
+
// A local remote is an absolute or ./ ../ path (resolved from the project) or a file:// URL.
|
|
87
|
+
const localPath = (url) => {
|
|
88
|
+
if (url.startsWith("file://")) {
|
|
89
|
+
try { return fileURLToPath(url); } catch { return null; } // host-qualified or malformed: not judged
|
|
90
|
+
}
|
|
91
|
+
return /^(\/|\.\.?\/)/.test(url) ? resolve(dir, url) : null;
|
|
92
|
+
};
|
|
93
|
+
const dead = git(dir, ["remote", "-v"]).split("\n").map((l) => /^(\S+)\t(.+) \((?:fetch|push)\)$/.exec(l))
|
|
94
|
+
.filter((m) => m && localPath(m[2]) && !existsSync(localPath(m[2]))).map((m) => m[1]);
|
|
95
|
+
const deadRemotes = [...new Set(dead)];
|
|
96
|
+
if (deadRemotes.length) {
|
|
97
|
+
symptoms.push({
|
|
98
|
+
detail: `remotos que apuntan a carpetas que ya no existen: ${deadRemotes.join(", ")}`,
|
|
99
|
+
fix: deadRemotes.map((n) => `git remote remove -- ${shellQuote(n)}`).join(" && "),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
if (symptoms.length === 0) return { ok: true, severity: "info", detail: "el repositorio está en orden" };
|
|
81
104
|
return {
|
|
82
105
|
ok: false,
|
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { parse as parseYaml } from "yaml";
|
|
9
9
|
import { runCommand } from "../utils/process.js";
|
|
10
|
+
import { assertGhIdentity } from "../hu/gh-identity.js";
|
|
10
11
|
|
|
11
12
|
// Scheduled or advisory kj workflows: they never block a PR, so they are never required.
|
|
12
13
|
const NOT_REQUIRED = new Set(["kj-mutation.yml", "kj-steward.yml"]);
|
|
@@ -34,7 +35,8 @@ const protectionPath = ({ owner, repo, branch }) => `repos/${owner}/${repo}/bran
|
|
|
34
35
|
|
|
35
36
|
function protectCommand({ checks, ...target }) {
|
|
36
37
|
const contexts = checks.map((c) => `-f ${shellQuote(`required_status_checks[contexts][]=${c}`)}`).join(" ");
|
|
37
|
-
|
|
38
|
+
// KJC-BUG-0401: enforce_admins=true; an agent working with an admin's token would skip every check otherwise.
|
|
39
|
+
return `gh api -X PUT ${protectionPath(target)} -F 'required_status_checks[strict]=false' ${contexts} -F enforce_admins=true -F required_pull_request_reviews=null -F restrictions=null -F allow_force_pushes=false`;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const addCommand = ({ missing, ...target }) =>
|
|
@@ -62,24 +64,37 @@ export async function protectionAdvice({ owner, repo, branch, checks, run = runC
|
|
|
62
64
|
if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
|
|
63
65
|
return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
|
|
64
66
|
}
|
|
65
|
-
const
|
|
67
|
+
const protection = JSON.parse(res.stdout || "{}");
|
|
68
|
+
const statusChecks = protection.required_status_checks;
|
|
66
69
|
if (!statusChecks) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
|
|
67
70
|
const existing = new Set(statusChecks.contexts ?? []);
|
|
68
71
|
const missing = checks.filter((c) => !existing.has(c));
|
|
69
|
-
|
|
72
|
+
// KJC-BUG-0401: required checks an admin skips are not required for an agent holding an admin's token.
|
|
73
|
+
const bindAdmins = protection.enforce_admins?.enabled === false ? `gh api -X POST ${protectionPath(target)}/enforce_admins` : null;
|
|
74
|
+
if (missing.length > 0) return { status: "missing", missing, command: [addCommand({ ...target, missing }), bindAdmins].filter(Boolean).join(" && ") };
|
|
75
|
+
if (bindAdmins) return { status: "admins-exempt", command: bindAdmins };
|
|
76
|
+
return { status: "ok" };
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
// Owner and repo go into a command an admin copies: only the characters GitHub allows in them.
|
|
73
80
|
const GITHUB_REMOTE = /github\.com[^:/]*[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
|
|
74
81
|
|
|
75
82
|
/** kj harden's last step: on a GitHub remote, what the base branch needs and who runs it. */
|
|
76
|
-
export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand }) {
|
|
83
|
+
export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand, identity = assertGhIdentity }) {
|
|
77
84
|
const checks = requiredChecksFrom(projectDir);
|
|
78
85
|
if (checks.length === 0) return null;
|
|
79
86
|
const remote = await run("git", ["-C", projectDir, "remote", "get-url", "origin"]).catch(() => null);
|
|
80
87
|
const match = remote?.exitCode === 0 ? GITHUB_REMOTE.exec(remote.stdout.trim()) : null;
|
|
81
88
|
if (!match) return null;
|
|
82
|
-
|
|
89
|
+
// KJC-BUG-0401: read the protection as the account the clone declares; another one gets a 404
|
|
90
|
+
// and kj said "not protected" about a protected branch.
|
|
91
|
+
let advice;
|
|
92
|
+
try {
|
|
93
|
+
identity({ projectDir });
|
|
94
|
+
advice = await protectionAdvice({ owner: match[1], repo: match[2], branch, checks, run });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
advice = { status: "unverified", reason: err.message, settings: settingsUrl({ owner: match[1], repo: match[2] }), missing: checks };
|
|
97
|
+
}
|
|
83
98
|
if (advice.status === "ok") {
|
|
84
99
|
logger.info?.(` • branch protection: ${branch} requires the ${checks.length} kj check(s)`);
|
|
85
100
|
return advice;
|
|
@@ -90,6 +105,7 @@ export async function adviseBranchProtection({ projectDir, branch, logger, run =
|
|
|
90
105
|
missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
|
|
91
106
|
"no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
|
|
92
107
|
unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
|
|
108
|
+
"admins-exempt": `${branch} requires the kj checks, but admins can merge without them, and an agent working with an admin's token would. An admin runs (kj never does):`,
|
|
93
109
|
}[advice.status];
|
|
94
110
|
logger.warn?.(` ⚠ branch protection: ${why}`);
|
|
95
111
|
logger.warn?.(` ${advice.command ?? advice.settings}`);
|
|
@@ -30,6 +30,11 @@ const HARNESS_PREFIX = ".karajan/harness/";
|
|
|
30
30
|
// with the repo is sealed with the supervisor, so a policy change enters only
|
|
31
31
|
// with the human's signed act. kj.config.yml is machine-local: CI never sees it.
|
|
32
32
|
export const GOVERNANCE_FILES = [".karajan/policy.yml", ".karajan/rules.yml"];
|
|
33
|
+
// KJC-BUG-0400: the CI workflows are sealed the same way, by hash: a PR could
|
|
34
|
+
// otherwise rewrite the check that judges it and pass with a forged seal.
|
|
35
|
+
const WORKFLOW_FILE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
36
|
+
/** A file the seal covers by its hash alone: the governance config and every CI workflow. */
|
|
37
|
+
export const isSealedConfig = (file) => GOVERNANCE_FILES.includes(file) || WORKFLOW_FILE.test(file);
|
|
33
38
|
|
|
34
39
|
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
35
40
|
|
|
@@ -83,7 +88,7 @@ export async function commitSupervisorRegeneration({
|
|
|
83
88
|
// KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
|
|
84
89
|
// un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
|
|
85
90
|
// card equivocada. Se para ANTES de pedir nada al humano y se nombra.
|
|
86
|
-
const foreign = run(["diff", "--cached", "--name-only"]).split("\n").filter((f) => f && !f.startsWith(HOOKS_PREFIX) && f !== PROVENANCE_FILE && !
|
|
91
|
+
const foreign = run(["diff", "--cached", "--name-only"]).split("\n").filter((f) => f && !f.startsWith(HOOKS_PREFIX) && f !== PROVENANCE_FILE && !isSealedConfig(f));
|
|
87
92
|
if (foreign.length > 0) {
|
|
88
93
|
throw new Error(
|
|
89
94
|
`harden --commit: hay cambios en el stage que no son del supervisor (${foreign.slice(0, 5).join(", ")}${foreign.length > 5 ? ", …" : ""}): commitéalos en su rama o sácalos del stage (git restore --staged <ficheros>) antes de sellar`,
|
|
@@ -92,8 +97,10 @@ export async function commitSupervisorRegeneration({
|
|
|
92
97
|
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
93
98
|
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
94
99
|
seal.confirm(deps.confirm);
|
|
95
|
-
const tracked = new Set(run(["ls-files", "--", ...GOVERNANCE_FILES]).split("\n").filter(Boolean));
|
|
96
|
-
const
|
|
100
|
+
const tracked = new Set(run(["ls-files", "--", ...GOVERNANCE_FILES, ".github/workflows"]).split("\n").filter(Boolean));
|
|
101
|
+
const onDisk = (() => { try { return readdirSync(join(projectDir, ".github", "workflows")).map((f) => `.github/workflows/${f}`); } catch { return []; } })();
|
|
102
|
+
const candidates = [...new Set([...GOVERNANCE_FILES, ...onDisk, ...tracked])].filter(isSealedConfig).sort();
|
|
103
|
+
const governance = candidates.filter((f) => existsSync(join(projectDir, f)) || tracked.has(f));
|
|
97
104
|
const governanceDrift = governance.length === 0 ? [] : run(["status", "--porcelain", "--", ...governance]).split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
|
|
98
105
|
const drift = [...supervisorDrift({ projectDir, gitFn: run }), ...governanceDrift];
|
|
99
106
|
// La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
|
|
@@ -67,6 +67,8 @@ export const WORKFLOWS = [
|
|
|
67
67
|
// PL-C (KJC-TSK-0735) — tier C del ADR 0001: re-check merge-blocking en CI
|
|
68
68
|
// (cubre el hook local manipulado). Solo se siembra con policy declarada;
|
|
69
69
|
// npx PINEADO a la versión del kj que corrió harden — jamás @latest en CI.
|
|
70
|
+
// KJC-BUG-0399: the judge is never the PR's code. A repo that ships kj itself
|
|
71
|
+
// judges with the kj of the BASE branch, checked out apart; the PR is only data.
|
|
70
72
|
export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
|
|
71
73
|
"name: Policy",
|
|
72
74
|
"on:",
|
|
@@ -87,7 +89,15 @@ export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
|
|
|
87
89
|
" env:",
|
|
88
90
|
" BASE_REF: ${{ github.base_ref }}",
|
|
89
91
|
" run: |",
|
|
90
|
-
|
|
92
|
+
' JUDGE="$RUNNER_TEMP/kj-judge"',
|
|
93
|
+
' git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}"',
|
|
94
|
+
' if git cat-file -e "origin/${BASE_REF}:bin/kj.js" 2>/dev/null; then',
|
|
95
|
+
' git worktree add --detach "$JUDGE" "origin/${BASE_REF}"',
|
|
96
|
+
' (cd "$JUDGE" && npm ci --ignore-scripts)',
|
|
97
|
+
' KJ="node $JUDGE/bin/kj.js"',
|
|
98
|
+
" else",
|
|
99
|
+
` KJ="npx --yes ${kjName}@${kjVersion}"`,
|
|
100
|
+
" fi",
|
|
91
101
|
' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
|
|
92
102
|
].join("\n");
|
|
93
103
|
|
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
* rewritten from the issues every time kj reads them. It is a view: changing it is done
|
|
5
5
|
* with kj hu move, never from the web, and the board refuses to edit it.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { rm } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { plansDir, savePlan } from "../plan/plan-store.js";
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
// KJC-BUG-0397: the board reads plan-*.json only and groups a plan by its projectDir;
|
|
12
|
+
// the mirror was plan_github-board.json with no projectDir, so it was never shown.
|
|
13
|
+
export const MIRROR_PLAN_ID = "plan-github-board";
|
|
14
|
+
const LEGACY_MIRROR_FILE = "plan_github-board.json";
|
|
10
15
|
|
|
11
16
|
/** The shared board as a plan the web board can show, marked read-only. */
|
|
12
17
|
export async function writeTeamMirror(projectDir, rows, { repo }) {
|
|
13
18
|
const now = new Date().toISOString();
|
|
14
19
|
const plan = {
|
|
15
|
-
version: 2, planId: MIRROR_PLAN_ID, name: "github", alias: "github",
|
|
20
|
+
version: 2, planId: MIRROR_PLAN_ID, name: "github", alias: "github", projectDir,
|
|
16
21
|
task: `Shared HU Board: GitHub Issues of ${repo}`,
|
|
17
22
|
status: "ready", readonly: "github", createdAt: now,
|
|
18
23
|
hus: rows.map((r) => ({
|
|
@@ -22,5 +27,6 @@ export async function writeTeamMirror(projectDir, rows, { repo }) {
|
|
|
22
27
|
})),
|
|
23
28
|
};
|
|
24
29
|
await savePlan(projectDir, plan);
|
|
30
|
+
await rm(path.join(plansDir(projectDir), LEGACY_MIRROR_FILE), { force: true });
|
|
25
31
|
return plan;
|
|
26
32
|
}
|
package/src/policy/engine.js
CHANGED
|
@@ -35,7 +35,8 @@ export const SUPERVISOR_DEFAULTS = [
|
|
|
35
35
|
// KJC-TSK-1033 (owner's decision, 13-oct): a policy change is authorized where
|
|
36
36
|
// it is USED (commit, CI, release), whatever wrote it: only the human's seal lifts it.
|
|
37
37
|
id: "defaults.governance.write",
|
|
38
|
-
|
|
38
|
+
// KJC-BUG-0400: the CI workflows too; a PR could rewrite the check that judges it.
|
|
39
|
+
pattern: /(^|\/)\.karajan\/(policy|rules)\.yml\b|(^|\/)\.github\/workflows\/[^/\s]+\.ya?ml\b/,
|
|
39
40
|
message: "es configuración de gobierno: solo entra sellada por la persona (kj harden --commit, firmado desde su móvil)",
|
|
40
41
|
},
|
|
41
42
|
];
|
|
@@ -11,7 +11,7 @@ import { resolve, sep } from "node:path";
|
|
|
11
11
|
import { renderCanonicalHook } from "../harden/harden-engine.js";
|
|
12
12
|
import { provenanceSignature } from "../harden/phone-sign.js";
|
|
13
13
|
import { canonicalHarnessBody } from "../harden/sentinel-hooks.js";
|
|
14
|
-
import {
|
|
14
|
+
import { isSealedConfig, PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
15
15
|
|
|
16
16
|
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
17
17
|
const HARNESS_PREFIX = ".karajan/harness/";
|
|
@@ -69,7 +69,7 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
69
69
|
if (typeof entry?.file !== "string") continue;
|
|
70
70
|
// KJC-TSK-1032: the governance config has no canonical render; the sealed
|
|
71
71
|
// hash, signed by the roster where there is one, is the proof.
|
|
72
|
-
if (
|
|
72
|
+
if (isSealedConfig(entry.file)) {
|
|
73
73
|
const abs = resolve(projectDir, entry.file);
|
|
74
74
|
let st = null;
|
|
75
75
|
try { st = lstatSync(abs); } catch { /* absent */ }
|