karajan-code 4.28.1 → 4.28.2
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/README.md +23 -9
- package/package.json +1 -1
- package/scripts/verify-pack.mjs +19 -1
- package/src/checks/config-files.js +86 -0
- package/src/cli/register-pipeline.js +4 -1
- package/src/commands/identity.js +5 -2
- package/src/commands/review-gate.js +16 -1
- package/src/harden/harden-engine.js +5 -2
- package/src/harden/hook-templates.js +25 -10
- package/src/harden/phone-sign.js +82 -13
- package/src/harden/supervisor-commit.js +7 -0
- package/src/review/verdict-store.js +40 -0
package/README.md
CHANGED
|
@@ -41,7 +41,29 @@ This repo runs under its own environment: every commit to karajan-code carries a
|
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Two ways in — pick your comfort level.
|
|
45
|
+
|
|
46
|
+
### Maggle mode — you never touch the terminal
|
|
47
|
+
|
|
48
|
+
Not a developer, or you just want it running? Paste this into your AI assistant (Claude Code, Codex, Cursor, VS Code):
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Set up Karajan in this project and start it for me: read
|
|
52
|
+
https://karajancode.com/go.md and do everything it says. I'm not a developer —
|
|
53
|
+
don't ask me technical questions, just get it running and tell me when it's ready.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It installs Karajan, prepares the project, and opens a single window with the board and your agent inside — stopping only if a step truly needs you (sudo or an account). Already installed? One command:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
kj go # or: kj go --window (the agent's terminal embedded in the board)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`kj go` detects your agent (asks which only if you have several), prepares the project silently the first time, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
|
|
63
|
+
|
|
64
|
+
### Developer mode — you stay in control
|
|
65
|
+
|
|
66
|
+
Want your agent to walk each step and stop at every permission? Paste:
|
|
45
67
|
|
|
46
68
|
```text
|
|
47
69
|
I want to use Karajan in this project: read https://karajancode.com/start.md
|
|
@@ -59,14 +81,6 @@ kj init && kj env install && kj harden && kj review --install-gate
|
|
|
59
81
|
git config core.hooksPath .karajan/hooks
|
|
60
82
|
```
|
|
61
83
|
|
|
62
|
-
Already installed and just want to work — even with no computing background? One command:
|
|
63
|
-
|
|
64
|
-
```sh
|
|
65
|
-
kj go
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
|
|
69
|
-
|
|
70
84
|
Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. The npm route is `npm install -g @karajan-family/code` (published as `karajan-code` before joining the scope; the legacy name still installs the same versions). All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
|
|
71
85
|
|
|
72
86
|
## The daily loop
|
package/package.json
CHANGED
package/scripts/verify-pack.mjs
CHANGED
|
@@ -33,8 +33,25 @@ const pkgName = pkg.name;
|
|
|
33
33
|
|
|
34
34
|
// Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
|
|
35
35
|
// runs otherwise) and force a non-interactive, quiet npm.
|
|
36
|
+
// KJC-BUG-0171: the tarball's postinstall registers karajan-mcp in
|
|
37
|
+
// ~/.claude.json / ~/.codex / ~/.karajan pointing at THIS install's path. Every
|
|
38
|
+
// verify install lives in a throwaway prefix, so those paths go stale the moment
|
|
39
|
+
// the temp dir is cleaned — a CONNECTION_CLOSED MCP left in the real user config.
|
|
40
|
+
// Redirect HOME so every global-config write lands in an isolated dir removed
|
|
41
|
+
// with the rest; seed a git identity so any commit still works without ~/.gitconfig.
|
|
42
|
+
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-home-"));
|
|
36
43
|
const { CLAUDECODE: _omit, ...cleanEnv } = process.env;
|
|
37
|
-
const childEnv = {
|
|
44
|
+
const childEnv = {
|
|
45
|
+
...cleanEnv,
|
|
46
|
+
npm_config_yes: "true",
|
|
47
|
+
CI: "1",
|
|
48
|
+
HOME: homeTmp,
|
|
49
|
+
USERPROFILE: homeTmp,
|
|
50
|
+
GIT_AUTHOR_NAME: "kj verify",
|
|
51
|
+
GIT_AUTHOR_EMAIL: "verify@kj.local",
|
|
52
|
+
GIT_COMMITTER_NAME: "kj verify",
|
|
53
|
+
GIT_COMMITTER_EMAIL: "verify@kj.local",
|
|
54
|
+
};
|
|
38
55
|
|
|
39
56
|
function run(cmd, args, opts = {}) {
|
|
40
57
|
return execFileSync(cmd, args, {
|
|
@@ -271,6 +288,7 @@ try {
|
|
|
271
288
|
console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
|
|
272
289
|
} finally {
|
|
273
290
|
if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
|
|
291
|
+
if (homeTmp && fs.existsSync(homeTmp)) fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
274
292
|
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
275
293
|
if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
|
|
276
294
|
if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* ~/.karajan/kj.config.yml (strategy: manual — structural fixes needed)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import fs from "node:fs/promises";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
14
16
|
import { exists } from "../utils/fs.js";
|
|
15
17
|
import { getConfigPath } from "../config.js";
|
|
16
18
|
import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
|
|
@@ -132,6 +134,89 @@ function createClaudeConfigCheck() {
|
|
|
132
134
|
};
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* KJC-BUG-0171: the karajan-mcp server path registered in ~/.claude.json.
|
|
139
|
+
* Returns the args entry that points at the MCP server (…/mcp/server.js), or
|
|
140
|
+
* null when karajan-mcp is not registered.
|
|
141
|
+
*/
|
|
142
|
+
export function karajanMcpServerPath(config) {
|
|
143
|
+
const server = config?.mcpServers?.["karajan-mcp"];
|
|
144
|
+
const args = Array.isArray(server?.args) ? server.args : [];
|
|
145
|
+
return args.find((a) => typeof a === "string" && /[\\/]mcp[\\/]server\.js$/.test(a)) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Repair a stale karajan-mcp registration in place: re-point it to this
|
|
150
|
+
* install's server when that server exists, or drop the dead entry when this
|
|
151
|
+
* install has no bundled server (the standalone binary). `serverExists` is
|
|
152
|
+
* injectable for tests. Mutates `config`; returns "repointed" | "removed" | null.
|
|
153
|
+
*/
|
|
154
|
+
export function repairKarajanMcpPath(config, correctServerPath, serverExists) {
|
|
155
|
+
const server = config?.mcpServers?.["karajan-mcp"];
|
|
156
|
+
if (!server) return null;
|
|
157
|
+
if (correctServerPath && serverExists(correctServerPath)) {
|
|
158
|
+
server.args = [correctServerPath];
|
|
159
|
+
server.cwd = path.resolve(path.dirname(correctServerPath), "..", "..");
|
|
160
|
+
return "repointed";
|
|
161
|
+
}
|
|
162
|
+
delete config.mcpServers["karajan-mcp"];
|
|
163
|
+
return "removed";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* karajan-mcp registration path validity (~/.claude.json) — KJC-BUG-0171.
|
|
168
|
+
* A verify/temp install used to register the MCP at a throwaway prefix; once
|
|
169
|
+
* cleaned, the path 404s and every session's MCP fails with CONNECTION_CLOSED,
|
|
170
|
+
* and the health check never caught it (it spawns its OWN bundled server).
|
|
171
|
+
*/
|
|
172
|
+
function createStaleMcpPathCheck() {
|
|
173
|
+
return {
|
|
174
|
+
name: "agent-config:karajan-mcp-path",
|
|
175
|
+
label: "MCP registration path (~/.claude.json)",
|
|
176
|
+
// KJC-BUG-0172: AUTO, not PROMPT — the runner never remediates a WARN
|
|
177
|
+
// check whose strategy is PROMPT, so the repair was inert under `kj doctor
|
|
178
|
+
// -y`. Re-pointing a dead entry to the current server (or dropping it) is
|
|
179
|
+
// deterministic and reversible on an already-broken entry; `--check-only`
|
|
180
|
+
// stays available to detect without touching the config.
|
|
181
|
+
strategy: STRATEGY.AUTO,
|
|
182
|
+
describe: "Re-point karajan-mcp in ~/.claude.json to this install's server (or drop a dead entry)",
|
|
183
|
+
async detect() {
|
|
184
|
+
const claudeJsonPath = path.join(os.homedir(), ".claude.json");
|
|
185
|
+
let config;
|
|
186
|
+
try {
|
|
187
|
+
config = JSON.parse(await fs.readFile(claudeJsonPath, "utf8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: true, severity: "info", detail: "Not present or unparseable (skipped)" };
|
|
190
|
+
}
|
|
191
|
+
const registered = karajanMcpServerPath(config);
|
|
192
|
+
if (!registered) return { ok: true, severity: "info", detail: "karajan-mcp not registered (skipped)" };
|
|
193
|
+
if (await exists(registered)) return { ok: true, severity: "info", detail: "karajan-mcp path valid" };
|
|
194
|
+
const correct = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "mcp", "server.js");
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
severity: "warn",
|
|
198
|
+
detail: `karajan-mcp points at a path that no longer exists: ${registered} — this is the CONNECTION_CLOSED cause`,
|
|
199
|
+
fix: `Re-point karajan-mcp to ${correct} (or remove the entry) in ~/.claude.json`,
|
|
200
|
+
extra: { claudeJsonPath, correct },
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
async remediate({ extra }) {
|
|
204
|
+
try {
|
|
205
|
+
const config = JSON.parse(await fs.readFile(extra.claudeJsonPath, "utf8"));
|
|
206
|
+
const action = repairKarajanMcpPath(config, extra.correct, existsSync);
|
|
207
|
+
if (!action) return { fixed: false, detail: "karajan-mcp entry vanished before repair" };
|
|
208
|
+
await fs.writeFile(extra.claudeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
209
|
+
return {
|
|
210
|
+
fixed: true,
|
|
211
|
+
detail: action === "repointed" ? `Re-pointed karajan-mcp to ${extra.correct}` : "Removed the dead karajan-mcp entry",
|
|
212
|
+
};
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { fixed: false, detail: `Failed to repair ~/.claude.json: ${err.message}` };
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
135
220
|
/**
|
|
136
221
|
* Codex config (~/.codex/config.toml) validity.
|
|
137
222
|
*/
|
|
@@ -211,6 +296,7 @@ export function getConfigFileChecks() {
|
|
|
211
296
|
createReviewRulesCheck(),
|
|
212
297
|
createCoderRulesCheck(),
|
|
213
298
|
createClaudeConfigCheck(),
|
|
299
|
+
createStaleMcpPathCheck(),
|
|
214
300
|
createCodexConfigCheck(),
|
|
215
301
|
createKjConfigYamlCheck(),
|
|
216
302
|
];
|
|
@@ -234,12 +234,15 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
234
234
|
.option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
|
|
235
235
|
.option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
|
|
236
236
|
.option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
|
|
237
|
+
.option("--prune", "Garbage-collect verdicts in .karajan/reviews older than the TTL (they age out as diffs change)")
|
|
238
|
+
.option("--prune-days <n>", "TTL in days for --prune (default: 14)")
|
|
239
|
+
.option("--dry-run", "With --prune: report what would be removed without deleting")
|
|
237
240
|
.option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
|
|
238
241
|
.action(async (task, flags) => {
|
|
239
242
|
await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
|
|
240
243
|
// ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
|
|
241
244
|
// the exact diff so the pre-commit hook can enforce cross-AI review.
|
|
242
|
-
if (flags.staged || flags.check || flags.range || flags.installGate) {
|
|
245
|
+
if (flags.staged || flags.check || flags.range || flags.installGate || flags.prune) {
|
|
243
246
|
await reviewGateCommand({ config, logger, flags: { ...flags, task } });
|
|
244
247
|
return;
|
|
245
248
|
}
|
package/src/commands/identity.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* no session and no flags → exit 1.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { enrollPhone } from "../harden/phone-sign.js";
|
|
11
|
+
import { addSigner, enrollPhone } from "../harden/phone-sign.js";
|
|
12
12
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
13
13
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
14
14
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -27,12 +27,15 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
27
27
|
// KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
|
|
28
28
|
if (action === "enroll-phone") {
|
|
29
29
|
try {
|
|
30
|
+
// KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
|
|
31
|
+
// base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
|
|
32
|
+
addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
|
|
30
33
|
enrollPhone(flags.publicKeyBase64, { home: deps.home });
|
|
31
34
|
} catch (err) {
|
|
32
35
|
log(`kj identity enroll-phone: ${err.message}`);
|
|
33
36
|
return 1;
|
|
34
37
|
}
|
|
35
|
-
log("Móvil enrolado: la clave pública
|
|
38
|
+
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.");
|
|
36
39
|
return 0;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
|
-
import { checkVerdict, diffHash } from "../review/verdict-store.js";
|
|
11
|
+
import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
|
|
12
12
|
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
@@ -144,6 +144,21 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
144
144
|
return { installed: true };
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
|
|
148
|
+
// on every saved verdict; this lets a human sweep on demand, with --dry-run
|
|
149
|
+
// to see what would go without deleting.
|
|
150
|
+
if (flags.prune) {
|
|
151
|
+
const dryRun = Boolean(flags.dryRun || flags.checkOnly);
|
|
152
|
+
const days = Number(flags.pruneDays);
|
|
153
|
+
const res = await pruneVerdicts({ projectDir, dryRun, ...(Number.isFinite(days) && days > 0 ? { maxAgeDays: days } : {}) });
|
|
154
|
+
console.log(
|
|
155
|
+
dryRun
|
|
156
|
+
? `kj review --prune (dry run): ${res.expired.length} of ${res.scanned} verdict(s) older than ${res.maxAgeDays}d would be removed`
|
|
157
|
+
: `kj review --prune: removed ${res.removed} verdict(s) older than ${res.maxAgeDays}d (${res.scanned - res.removed} kept)`,
|
|
158
|
+
);
|
|
159
|
+
return { pruned: res.removed, dryRun };
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
const diff = await rawDiff(flags.range);
|
|
148
163
|
|
|
149
164
|
const card = await enforceCardFirst({ config, projectDir });
|
|
@@ -12,7 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
14
14
|
import { runCommand } from "../utils/process.js";
|
|
15
|
-
import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
|
|
15
|
+
import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
|
|
16
16
|
|
|
17
17
|
const BLOCK_VERSION = 1;
|
|
18
18
|
const HOOKS_DIR = join(".karajan", "hooks");
|
|
@@ -67,7 +67,10 @@ export async function installHooks({
|
|
|
67
67
|
const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
|
|
68
68
|
const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
|
|
69
69
|
if (rawGlobal && rawGlobal !== HOOKS_DIR) {
|
|
70
|
-
|
|
70
|
+
// KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
|
|
71
|
+
// on the human's machine) so the provenance stores the portable dir and CI
|
|
72
|
+
// recomputes the SAME bytes — an absolute home path is not verifiable there.
|
|
73
|
+
globalHooksDir = toPortableHooksDir(rawGlobal);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
const absHooksDir = join(projectDir, HOOKS_DIR);
|
|
@@ -27,16 +27,24 @@ export const PROFILE_HOOKS = {
|
|
|
27
27
|
// hooks dir, silently disabling personal guards. When a previous global dir
|
|
28
28
|
// is known, every generated hook ends by chaining its namesake there —
|
|
29
29
|
// guarded, so machines without it are unaffected.
|
|
30
|
+
// KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
|
|
31
|
+
// absolute home path — a dir under the home resolves through $HOME so the same
|
|
32
|
+
// generated content is valid on every machine and its provenance stays
|
|
33
|
+
// verifiable IN CI (a different home), with no local filesystem layout leaking
|
|
34
|
+
// into a public repo. Normalization happens ONCE, at generation, on the human's
|
|
35
|
+
// machine; the stored/rendered value is already portable, so a render on any
|
|
36
|
+
// other home (CI) reproduces identical bytes. `home` is injectable for tests.
|
|
37
|
+
export function toPortableHooksDir(dir, home = homedir()) {
|
|
38
|
+
if (!dir) return dir;
|
|
39
|
+
if (dir.startsWith("$HOME")) return dir;
|
|
40
|
+
if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
|
|
41
|
+
if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
function chainToGlobal(hook, globalHooksDir) {
|
|
31
46
|
if (!globalHooksDir) return [];
|
|
32
|
-
|
|
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;
|
|
47
|
+
const dir = toPortableHooksDir(globalHooksDir);
|
|
40
48
|
return [
|
|
41
49
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
42
50
|
`if [ -x "${dir}/${hook}" ]; then`,
|
|
@@ -114,12 +122,19 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
114
122
|
"# the same precise pattern (tool MENTIONS stay legal; attribution not).",
|
|
115
123
|
"# The guard's own definition files are excluded by literal path — they",
|
|
116
124
|
"# CONTAIN the pattern; excluding a path that does not exist is harmless.",
|
|
117
|
-
|
|
125
|
+
"# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
|
|
126
|
+
"# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
|
|
127
|
+
"# that versions them must not self-detect in the consumer repo.",
|
|
128
|
+
`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' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
|
|
118
129
|
" echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
|
|
119
130
|
"fi",
|
|
120
131
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
121
132
|
"# a staged diff only enters with a recorded cross-AI approved verdict.",
|
|
122
|
-
"
|
|
133
|
+
"# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
|
|
134
|
+
"# not merely present — so the bootstrap commit that INTRODUCES it is",
|
|
135
|
+
"# exempt (no gate can exist before it does), and it cannot be bypassed",
|
|
136
|
+
"# by deleting the working-tree file.",
|
|
137
|
+
"if git cat-file -e HEAD:.karajan/review-gate 2>/dev/null; then",
|
|
123
138
|
" if ! command -v kj >/dev/null 2>&1; then",
|
|
124
139
|
" echo 'kj: review gate is enabled but kj is not installed — see karajancode.com/docs/getting-started/installation'; exit 1",
|
|
125
140
|
" fi",
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -32,21 +32,78 @@ async function relayConfig(fetchFn) {
|
|
|
32
32
|
}
|
|
33
33
|
const SIGN_PAGE = "https://karajancode.com/sign";
|
|
34
34
|
const POLL_INTERVAL_MS = 2000;
|
|
35
|
-
const TTL_MS =
|
|
35
|
+
const TTL_MS = 60000;
|
|
36
|
+
|
|
37
|
+
/** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
|
|
38
|
+
function makeSpinner() {
|
|
39
|
+
if (!process.stdout?.isTTY) return { tick() {}, stop() {} };
|
|
40
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
41
|
+
let i = 0;
|
|
42
|
+
return {
|
|
43
|
+
tick() { process.stdout.write(`\r${frames[i++ % frames.length]} esperando la firma del móvil…`); },
|
|
44
|
+
stop() { process.stdout.write("\r\x1b[K"); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
36
47
|
|
|
37
48
|
const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
|
|
38
49
|
|
|
50
|
+
// KJC-TSK-0831 (ADR 0010): el padrón de firmantes vive VERSIONADO en el repo,
|
|
51
|
+
// no una única clave local — así un equipo comparte la confianza y el CI puede
|
|
52
|
+
// verificar (0823) contra el mismo conjunto. La legacy ~/.karajan sigue valiendo.
|
|
53
|
+
const signersPath = (projectDir) => join(projectDir ?? process.cwd(), ".karajan", "supervisor-signers.json");
|
|
54
|
+
|
|
55
|
+
/** Valida una clave pública ed25519 raw en base64 canónico (32 bytes). */
|
|
56
|
+
export function validatePhonePublicKey(publicKeyBase64) {
|
|
57
|
+
const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
|
|
58
|
+
if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
|
|
59
|
+
throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
|
|
60
|
+
}
|
|
61
|
+
return publicKeyBase64;
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
|
|
40
65
|
|
|
41
66
|
export const readEnrolledKey = ({ home } = {}) =>
|
|
42
67
|
JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
|
|
43
68
|
|
|
44
|
-
/**
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
/** Claves públicas del padrón versionado del repo (o [] si no hay). */
|
|
70
|
+
export function readSigners({ projectDir } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const doc = JSON.parse(readFileSync(signersPath(projectDir), "utf8"));
|
|
73
|
+
return Array.isArray(doc?.signers) ? doc.signers.map((s) => s.publicKey).filter(Boolean) : [];
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
49
76
|
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Conjunto de claves AUTORIZADAS a sellar: el padrón del repo ∪ la clave legacy
|
|
81
|
+
* de ~/.karajan (compat). Sin fallbacks silenciosos: si no hay ninguna, el Set
|
|
82
|
+
* está vacío y la verificación fallará ruidosamente.
|
|
83
|
+
*/
|
|
84
|
+
export function readEnrolledKeys({ projectDir, home } = {}) {
|
|
85
|
+
const keys = new Set(readSigners({ projectDir }));
|
|
86
|
+
if (isPhoneEnrolled({ home })) keys.add(readEnrolledKey({ home }));
|
|
87
|
+
return keys;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Añade una clave pública al padrón versionado del repo (dedup). Acto humano. */
|
|
91
|
+
export function addSigner(publicKeyBase64, { projectDir, label } = {}) {
|
|
92
|
+
validatePhonePublicKey(publicKeyBase64);
|
|
93
|
+
const path = signersPath(projectDir);
|
|
94
|
+
let doc = { signers: [] };
|
|
95
|
+
try { doc = JSON.parse(readFileSync(path, "utf8")); } catch { /* new file */ }
|
|
96
|
+
if (!Array.isArray(doc.signers)) doc.signers = [];
|
|
97
|
+
if (doc.signers.some((s) => s.publicKey === publicKeyBase64)) return doc;
|
|
98
|
+
doc.signers.push({ publicKey: publicKeyBase64, label: label ?? null, enrolledAt: new Date().toISOString() });
|
|
99
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
100
|
+
writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
101
|
+
return doc;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Valida y persiste la clave pública del móvil (legacy: clave única ~/.karajan). */
|
|
105
|
+
export function enrollPhone(publicKeyBase64, { home } = {}) {
|
|
106
|
+
validatePhonePublicKey(publicKeyBase64);
|
|
50
107
|
mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
|
|
51
108
|
const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
|
|
52
109
|
writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
@@ -98,22 +155,34 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
98
155
|
const signUrl = `${SIGN_PAGE}?c=${cid}`;
|
|
99
156
|
drawQr(signUrl);
|
|
100
157
|
logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
|
|
158
|
+
logger.info?.("phone-sign: esperando la firma del móvil…");
|
|
159
|
+
const spinner = deps.spinner ?? makeSpinner();
|
|
101
160
|
const deadline = now() + TTL_MS;
|
|
102
161
|
while (now() < deadline) {
|
|
103
162
|
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})`);
|
|
163
|
+
if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
|
|
105
164
|
const fields = (await res.json()).fields ?? {};
|
|
106
165
|
if (fields.state?.stringValue === "signed") {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
166
|
+
spinner.stop();
|
|
167
|
+
// KJC-TSK-0831 (ADR 0010): la clave que firma debe estar en el PADRÓN
|
|
168
|
+
// autorizado (repo ∪ legacy). El doc es transporte; la verdad es el padrón.
|
|
169
|
+
const authorized = readEnrolledKeys({ projectDir: deps.projectDir, home: deps.home });
|
|
170
|
+
const signerKey = fields.publicKey?.stringValue;
|
|
171
|
+
if (!signerKey || !authorized.has(signerKey)) {
|
|
172
|
+
return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
|
|
110
173
|
}
|
|
111
174
|
const payload = canonicalPayload({ cid, nonce, project, files });
|
|
112
|
-
const
|
|
113
|
-
|
|
175
|
+
const signature = fields.signature?.stringValue ?? "";
|
|
176
|
+
const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
|
|
177
|
+
if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
|
|
178
|
+
// KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
|
|
179
|
+
// la provenance y CI pueda re-verificar server-side (clave del padrón).
|
|
180
|
+
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
181
|
+
return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
|
|
114
182
|
}
|
|
115
|
-
|
|
183
|
+
spinner.tick();
|
|
116
184
|
await sleep(POLL_INTERVAL_MS);
|
|
117
185
|
}
|
|
186
|
+
spinner.stop();
|
|
118
187
|
return { ok: false, reason: "caducado" };
|
|
119
188
|
}
|
|
@@ -123,6 +123,7 @@ export async function commitSupervisorRegeneration({
|
|
|
123
123
|
// es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
|
|
124
124
|
// files/hashes de la provenance (normalizados al par {file, sha256}).
|
|
125
125
|
const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
|
|
126
|
+
let signatureBlock = null;
|
|
126
127
|
if (phone.enrolled({})) {
|
|
127
128
|
const signed = await phone.request({
|
|
128
129
|
project: basename(projectDir),
|
|
@@ -133,6 +134,11 @@ export async function commitSupervisorRegeneration({
|
|
|
133
134
|
if (!signed.ok) {
|
|
134
135
|
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
|
+
// KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
|
|
138
|
+
// re-verifique server-side que un humano (clave del padrón) aprobó.
|
|
139
|
+
if (signed.challenge && signed.signature) {
|
|
140
|
+
signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
|
|
141
|
+
}
|
|
136
142
|
}
|
|
137
143
|
const who = readIdentity(projectDir);
|
|
138
144
|
const provenance = {
|
|
@@ -141,6 +147,7 @@ export async function commitSupervisorRegeneration({
|
|
|
141
147
|
generation,
|
|
142
148
|
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
143
149
|
files: hashed,
|
|
150
|
+
...(signatureBlock ? { signature: signatureBlock } : {}),
|
|
144
151
|
};
|
|
145
152
|
writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
|
146
153
|
recordGateDecision(projectDir, {
|
|
@@ -16,6 +16,11 @@ import { runCommand } from "../utils/process.js";
|
|
|
16
16
|
|
|
17
17
|
const STORE_DIR = path.join(".karajan", "reviews");
|
|
18
18
|
|
|
19
|
+
// KJC-BUG-0173: a verdict is keyed by one exact diff hash, so once that diff is
|
|
20
|
+
// committed or changed its hash is never looked up again — anything older than
|
|
21
|
+
// this TTL is dead weight and safe to shed. Nothing live stays staged for weeks.
|
|
22
|
+
export const VERDICT_TTL_DAYS = 14;
|
|
23
|
+
|
|
19
24
|
export function diffHash(diff) {
|
|
20
25
|
// trimEnd: runners differ on the final newline (execa strips it, raw
|
|
21
26
|
// git keeps it) — trailing whitespace must not void a verdict.
|
|
@@ -36,9 +41,44 @@ export async function saveVerdict(projectDir, diff, verdict) {
|
|
|
36
41
|
const file = verdictPath(projectDir, hash);
|
|
37
42
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
38
43
|
await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
44
|
+
// KJC-BUG-0173: a review is the natural moment to shed dead verdicts.
|
|
45
|
+
// Best-effort — hygiene must never break a save.
|
|
46
|
+
try { await pruneVerdicts({ projectDir }); } catch { /* hygiene, not correctness */ }
|
|
39
47
|
return record;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Opportunistic GC for the verdict store: removes verdict files whose mtime is
|
|
52
|
+
* older than `maxAgeDays`. A verdict only counts for a byte-identical diff, so
|
|
53
|
+
* an aged one can never be re-checked. `now`/`dryRun` injectable for tests.
|
|
54
|
+
* @returns {Promise<{scanned:number, removed:number, expired:string[], dryRun:boolean, maxAgeDays:number}>}
|
|
55
|
+
*/
|
|
56
|
+
export async function pruneVerdicts({ projectDir, maxAgeDays = VERDICT_TTL_DAYS, now = Date.now, dryRun = false } = {}) {
|
|
57
|
+
const dir = path.join(projectDir || process.cwd(), STORE_DIR);
|
|
58
|
+
const cutoff = now() - maxAgeDays * 86400000;
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = await fs.readdir(dir);
|
|
62
|
+
} catch {
|
|
63
|
+
return { scanned: 0, removed: 0, expired: [], dryRun, maxAgeDays };
|
|
64
|
+
}
|
|
65
|
+
const jsons = names.filter((n) => n.endsWith(".json"));
|
|
66
|
+
const expired = [];
|
|
67
|
+
for (const name of jsons) {
|
|
68
|
+
try {
|
|
69
|
+
const st = await fs.stat(path.join(dir, name));
|
|
70
|
+
if (st.mtimeMs < cutoff) expired.push(name);
|
|
71
|
+
} catch { /* vanished mid-scan */ }
|
|
72
|
+
}
|
|
73
|
+
let removed = 0;
|
|
74
|
+
if (!dryRun) {
|
|
75
|
+
for (const name of expired) {
|
|
76
|
+
try { await fs.unlink(path.join(dir, name)); removed += 1; } catch { /* already gone */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { scanned: jsons.length, removed, expired, dryRun, maxAgeDays };
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
export async function loadVerdict(projectDir, hash) {
|
|
43
83
|
try {
|
|
44
84
|
return JSON.parse(await fs.readFile(verdictPath(projectDir, hash), "utf8"));
|