karajan-code 4.30.0 → 4.31.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/src/checks/release-check.js +8 -1
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/env.js +12 -0
- package/src/commands/go.js +17 -2
- package/src/commands/harden.js +4 -1
- package/src/commands/hu.js +13 -1
- package/src/commands/init.js +38 -5
- package/src/harden/harness-hooks.js +2 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/sentinel-hooks.js +29 -7
- package/src/identity/bootstrap.js +2 -2
- package/src/orchestrator/fs-leak-detector.js +18 -0
- package/src/orchestrator/stages/coder-stage.js +8 -3
- package/src/privacy/scan.js +14 -1
- package/src/review/rag-requirement.js +13 -4
- package/src/utils/pending-user-action.js +7 -4
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { isAbsolute, join } from "node:path";
|
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
11
|
import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
|
|
12
12
|
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
13
|
+
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
13
14
|
|
|
14
15
|
// KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
|
|
15
16
|
// policy IN FORCE now, not the one each PR was merged under. Artifact rules
|
|
@@ -28,7 +29,13 @@ async function policyRangeCheck(projectDir) {
|
|
|
28
29
|
: ["-C", projectDir, "ls-tree", "-r", "--name-only", "HEAD"]);
|
|
29
30
|
if (listed.exitCode !== 0) throw new Error((listed.stderr || "git failed").trim());
|
|
30
31
|
const files = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
31
|
-
const
|
|
32
|
+
const raw = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
|
|
33
|
+
// KJC-BUG-0178 (ADR 0009): the supervisor seal trips the very rule that
|
|
34
|
+
// protects the supervisor. `kj review` and `kj policy check` already lift
|
|
35
|
+
// what the sealed provenance backs; without this the release check blocked
|
|
36
|
+
// every version cut after a human `kj harden --commit`, by its own seal.
|
|
37
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations: raw });
|
|
38
|
+
const violations = sup.violations;
|
|
32
39
|
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
33
40
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
34
41
|
if (hard.length > 0) {
|
|
@@ -34,6 +34,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
34
34
|
.option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
|
|
35
35
|
.option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
|
|
36
36
|
.option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
|
|
37
|
+
.option("--no-sonar", "Do not start the SonarQube container (Docker). The review gate still needs it: start it later with 'kj sonar start'")
|
|
37
38
|
.option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
|
|
38
39
|
.action(async (flags) => {
|
|
39
40
|
await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
|
package/src/commands/env.js
CHANGED
|
@@ -115,6 +115,18 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
115
115
|
try {
|
|
116
116
|
const h = await hardenCommand({ projectDir, logger: console });
|
|
117
117
|
if (h?.ok === false) throw new Error(h.error || "kj harden failed");
|
|
118
|
+
// KJC-BUG-0188: a headless harden never binds an identity by itself, and
|
|
119
|
+
// it must not. Blocking here beats the Sentinel denying every git and gh
|
|
120
|
+
// call later, with nothing on screen tying that back to this install.
|
|
121
|
+
if (h?.identityPending) {
|
|
122
|
+
result.exitCode = PENDING_EXIT_CODE;
|
|
123
|
+
result.pendingBlock = renderPendingBlock(
|
|
124
|
+
[{ tool: "identity", action: "needs-user", reason: h.identityPending, command: h.identityCommand }],
|
|
125
|
+
{ retry: "kj env install", why: "this clone has no declared identity, and kj never binds one on its own" },
|
|
126
|
+
);
|
|
127
|
+
console.log(result.pendingBlock);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
118
130
|
await reviewGateCommand({ config, flags: { installGate: true } });
|
|
119
131
|
console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
|
|
120
132
|
} catch (err) {
|
package/src/commands/go.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
14
|
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
15
|
import { envInstallCommand } from "./env.js";
|
|
16
|
+
import { ensureGitRepo } from "./init.js";
|
|
16
17
|
import { boardCommand } from "./board.js";
|
|
17
18
|
// The interactive launchers a muggle can live inside (v1: the two the epic
|
|
18
19
|
// names). Auth heuristics are the same cheap file checks reviewer-fallback
|
|
@@ -44,7 +45,7 @@ export function buildGoPrompt() {
|
|
|
44
45
|
].join("\n");
|
|
45
46
|
}
|
|
46
47
|
async function defaultPrepare({ config, logger }) {
|
|
47
|
-
|
|
48
|
+
return envInstallCommand({ config, logger, flags: { yes: true } });
|
|
48
49
|
}
|
|
49
50
|
export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
|
|
50
51
|
const port = config.hu_board?.port || 4000;
|
|
@@ -95,7 +96,21 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
|
|
|
95
96
|
// Prepare ONCE: decisions already taken are never re-asked.
|
|
96
97
|
if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
|
|
97
98
|
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
98
|
-
|
|
99
|
+
// KJC-BUG-0191: empezar en una carpeta vacía es lo natural, y kj env
|
|
100
|
+
// install se niega a correr sin repositorio. Lo creamos nosotros.
|
|
101
|
+
if (!ensureGitRepo({ projectDir, logger })) {
|
|
102
|
+
logger.error?.("No he podido preparar el control de versiones de tu proyecto (git). Instálalo y vuelve a escribir: kj go");
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const prepared = await (deps.prepare ?? defaultPrepare)({ config, logger });
|
|
107
|
+
// Algo depende de tus manos (arriba tienes el detalle). Sin eso el
|
|
108
|
+
// proyecto quedaría a medias, así que paramos aquí en vez de seguir.
|
|
109
|
+
if (prepared?.exitCode) {
|
|
110
|
+
logger.error?.("Falta una cosa que solo puedes hacer tú, la tienes justo arriba. Cuando la hagas, vuelve a escribir: kj go");
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
99
114
|
}
|
|
100
115
|
const prompt = (deps.prompt ?? buildGoPrompt)();
|
|
101
116
|
// --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
|
package/src/commands/harden.js
CHANGED
|
@@ -200,7 +200,10 @@ export async function hardenCommand({
|
|
|
200
200
|
if (!dryRun) {
|
|
201
201
|
const id = await ensureIdentity({ projectDir, logger });
|
|
202
202
|
out.identity = id.declared ? id.identity : null;
|
|
203
|
-
if (id.pending)
|
|
203
|
+
if (id.pending) {
|
|
204
|
+
out.identityPending = id.pending;
|
|
205
|
+
out.identityCommand = id.command;
|
|
206
|
+
}
|
|
204
207
|
}
|
|
205
208
|
// KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
|
|
206
209
|
// del supervisor con procedencia sellada. Acto humano — el guard vive en
|
package/src/commands/hu.js
CHANGED
|
@@ -121,6 +121,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
121
121
|
console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
|
|
122
122
|
}
|
|
123
123
|
const plan = await backlogPlan(projectDir);
|
|
124
|
+
/** Lowest free HU-#### in this plan — ids are permanent, never reused. */
|
|
125
|
+
const nextHuRef = (p) => {
|
|
126
|
+
const taken = new Set((p.hus || []).map((h) => h.short_id).filter(Boolean));
|
|
127
|
+
let n = 1;
|
|
128
|
+
while (taken.has(`HU-${String(n).padStart(4, "0")}`)) n += 1;
|
|
129
|
+
return `HU-${String(n).padStart(4, "0")}`;
|
|
130
|
+
};
|
|
124
131
|
// KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
|
|
125
132
|
// recreate "fix" an agent improvises loses history and duplicates ids —
|
|
126
133
|
// refuse it with the norm spelled out.
|
|
@@ -145,7 +152,12 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
145
152
|
.flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
146
153
|
const hu = addHu(plan, {
|
|
147
154
|
title,
|
|
148
|
-
|
|
155
|
+
// KJC-BUG-0189: the card-first gate demands a branch that references a
|
|
156
|
+
// card, and it is this command the gate sends the agent to. The
|
|
157
|
+
// canonical plan id (hu_plan-<stamp>-<rand>_001) does not match
|
|
158
|
+
// CARD_REF_RE, so without an explicit --id the card kj just created
|
|
159
|
+
// could not satisfy kj's own gate. Sequential HU-#### does.
|
|
160
|
+
short_id: flags.id || nextHuRef(plan),
|
|
149
161
|
acceptance_criteria: [...splitLines(flags.ac), ...splitLines(flags.criteria)],
|
|
150
162
|
acceptance_tests: splitLines(flags.tests),
|
|
151
163
|
scope: flags.scope || null,
|
package/src/commands/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -743,6 +744,25 @@ export async function resolveConfigScope({ flags, interactive }) {
|
|
|
743
744
|
return { configPath: getConfigPath(), scope: "global" };
|
|
744
745
|
}
|
|
745
746
|
|
|
747
|
+
/**
|
|
748
|
+
* KJC-BUG-0187: the enforcement gates live in git hooks, so a project without
|
|
749
|
+
* a repo gets no harness at all. Init creates it, exactly like `kj run` does
|
|
750
|
+
* (src/orchestrator/config-init.js), and reports failure instead of carrying
|
|
751
|
+
* on half-installed. No seed commit: that is the bootstrap commit's job.
|
|
752
|
+
*/
|
|
753
|
+
export function ensureGitRepo({ projectDir, logger, gitFn = null }) {
|
|
754
|
+
if (isGitRepo(projectDir)) return true;
|
|
755
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, stdio: "pipe" }));
|
|
756
|
+
try {
|
|
757
|
+
run(["init"]);
|
|
758
|
+
logger?.info?.("Initialized git repository (no seed commit) — the quality harness needs it");
|
|
759
|
+
return true;
|
|
760
|
+
} catch (err) {
|
|
761
|
+
logger?.warn?.(`git init failed: ${err.message}`);
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
746
766
|
export async function initCommand({ logger, flags = {} }) {
|
|
747
767
|
// AB-B (KJC-TSK-0656): with --json, stdout is a machine contract — every
|
|
748
768
|
// human log moves to stderr so the only stdout line is the summary object.
|
|
@@ -888,20 +908,29 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
888
908
|
}
|
|
889
909
|
}
|
|
890
910
|
|
|
891
|
-
|
|
911
|
+
// KJC-BUG-0184: the container is a heavy side effect — opt-out: --no-sonar.
|
|
912
|
+
// Only the bootstrap is skipped; the review gate still demands Sonar.
|
|
913
|
+
const skipSonar = flags?.noSonar === true || flags?.sonar === false;
|
|
914
|
+
if (skipSonar) {
|
|
915
|
+
logger.info("SonarQube container not started (--no-sonar). Start it later with 'kj sonar start'.");
|
|
916
|
+
} else {
|
|
917
|
+
await setupSonarQube(config, logger, { interactive });
|
|
918
|
+
}
|
|
892
919
|
await scaffoldCiGateway(config, flags, logger);
|
|
893
920
|
|
|
894
921
|
// Install the quality harness (git hooks, lint/format/commit config, CI
|
|
895
922
|
// quality gates, agent guidelines) through the SAME engine as `kj harden`,
|
|
896
923
|
// so init and harden never drift. Opt-out: --no-harden. Needs a git repo.
|
|
897
924
|
const skipHarden = flags?.noHarden === true || flags?.harden === false;
|
|
925
|
+
let hardened = false;
|
|
898
926
|
if (skipHarden) {
|
|
899
927
|
logger.info("Quality harness skipped (--no-harden).");
|
|
900
|
-
} else if (
|
|
928
|
+
} else if (ensureGitRepo({ projectDir: process.cwd(), logger })) {
|
|
901
929
|
logger.info("Installing quality harness (kj harden)...");
|
|
902
930
|
await hardenCommand({ projectDir: process.cwd(), logger });
|
|
931
|
+
hardened = true;
|
|
903
932
|
} else {
|
|
904
|
-
logger.
|
|
933
|
+
logger.warn("Quality harness NOT installed: this is not a git repository and `git init` failed — Karajan's guarantees live in git hooks. Fix git, then run `kj harden`.");
|
|
905
934
|
}
|
|
906
935
|
|
|
907
936
|
// Persist any changes setupSonarQube made (token, etc.) back to disk.
|
|
@@ -925,7 +954,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
925
954
|
if (flags.json) {
|
|
926
955
|
console.log(JSON.stringify({
|
|
927
956
|
ok: true, configPath, scope, interactive,
|
|
928
|
-
skipped: ["ollama", "rtk", "squeezr", "qmd", "harden"].filter((t) => flags[t] === false),
|
|
957
|
+
skipped: ["ollama", "rtk", "squeezr", "qmd", "sonar", "harden"].filter((t) => flags[t] === false),
|
|
929
958
|
}));
|
|
930
959
|
return;
|
|
931
960
|
}
|
|
@@ -933,7 +962,11 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
933
962
|
// Clear close so a first-time user knows setup finished and what to do next,
|
|
934
963
|
// instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
|
|
935
964
|
logger.info("");
|
|
936
|
-
|
|
965
|
+
// KJC-BUG-0187: never declare a setup that did not happen. Without the
|
|
966
|
+
// harness there are no gates, and saying otherwise is the worst outcome.
|
|
967
|
+
logger.info(hardened || skipHarden
|
|
968
|
+
? "✓ Karajan is set up in this project."
|
|
969
|
+
: "⚠ Karajan is set up EXCEPT the quality harness — without it there are no gates.");
|
|
937
970
|
logger.info(' Next: kj run "describe what to build or fix"');
|
|
938
971
|
logger.info(" More: kj --help · kj doctor (check your setup)");
|
|
939
972
|
}
|
|
@@ -16,6 +16,8 @@ const SCRIPT_BODY = `#!/usr/bin/env node
|
|
|
16
16
|
// kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
|
|
17
17
|
// tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
|
|
18
18
|
// so a gate bug never bricks the session.
|
|
19
|
+
import console from "node:console";
|
|
20
|
+
import process from "node:process";
|
|
19
21
|
import { existsSync } from "node:fs";
|
|
20
22
|
let raw = "";
|
|
21
23
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -60,8 +60,9 @@ function chainToGlobal(hook, globalHooksDir) {
|
|
|
60
60
|
function baseBranchGuard(baseBranch) {
|
|
61
61
|
if (!baseBranch) return [];
|
|
62
62
|
return [
|
|
63
|
-
"# Branch-first guard — the base branch only moves via PR.",
|
|
64
|
-
|
|
63
|
+
"# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
|
|
64
|
+
"# the bootstrap commit is exempt, there is no commit to branch from yet.",
|
|
65
|
+
'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
|
|
65
66
|
' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
66
67
|
` if [ "$current_branch" = "${baseBranch}" ]; then`,
|
|
67
68
|
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
|
|
@@ -28,6 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
|
|
|
28
28
|
const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
|
|
29
29
|
// Single source for every sentinel script: state, branch, classification,
|
|
30
30
|
// violations, and escape recording.
|
|
31
|
+
import console from "node:console";
|
|
31
32
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
32
33
|
import { execSync, spawnSync } from "node:child_process";
|
|
33
34
|
import { dirname, join } from "node:path";
|
|
@@ -71,9 +72,18 @@ export const foreignLane = (targetDir) => {
|
|
|
71
72
|
// the tracker. The method does not advance until the board is true.
|
|
72
73
|
export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
|
|
73
74
|
export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
|
|
75
|
+
// KJC-BUG-0185: bootstrap phase = the repo has no commit yet. There is no base
|
|
76
|
+
// branch to protect and no coverage to demand over a tree that does not exist,
|
|
77
|
+
// and \`git diff HEAD\` cannot even run. The phase is a verifiable FACT, never a
|
|
78
|
+
// flag, and it ends at the first commit.
|
|
79
|
+
export const bootstrapPhase = () => {
|
|
80
|
+
try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
|
|
81
|
+
catch { return true; }
|
|
82
|
+
};
|
|
74
83
|
export const violations = (s, branch) => {
|
|
75
84
|
const v = pendingMoves(s).map(pendingText);
|
|
76
85
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
86
|
+
if (bootstrapPhase()) return v;
|
|
77
87
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
78
88
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
79
89
|
if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
@@ -120,9 +130,10 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
120
130
|
// kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
|
|
121
131
|
// Records deterministic method facts per session; never blocks, never fails
|
|
122
132
|
// a tool call (PostToolUse, always exit 0).
|
|
133
|
+
import process from "node:process";
|
|
123
134
|
import { relative } from "node:path";
|
|
124
135
|
import { spawnSync } from "node:child_process";
|
|
125
|
-
import {
|
|
136
|
+
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
126
137
|
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG"];
|
|
127
138
|
let raw = "";
|
|
128
139
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -151,10 +162,15 @@ process.stdin.on("end", () => {
|
|
|
151
162
|
// repo-relative sources it returned. Neither the CLI nor the MCP know the
|
|
152
163
|
// host's session id: the harness records, they do not. Hits outside the
|
|
153
164
|
// repo (another indexed project) are not ours and are dropped.
|
|
154
|
-
|
|
165
|
+
// KJC-BUG-0190: an index with nothing in it (a brand new project) answers
|
|
166
|
+
// every question with silence, so the gate would demand what no query can
|
|
167
|
+
// deliver. kj says so explicitly, in both its human and json forms.
|
|
168
|
+
const EMPTY_INDEX = /No chunks indexed yet|"empty"\\s*:\\s*true/;
|
|
169
|
+
const recordRag = (query, sources, answer = "") => {
|
|
155
170
|
const state = load();
|
|
156
171
|
const s = session(state, sid);
|
|
157
172
|
s.at = Date.now();
|
|
173
|
+
if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
|
|
158
174
|
const hits = [];
|
|
159
175
|
for (const src of sources) {
|
|
160
176
|
const rel = relative(ROOT, String(src)).split(String.fromCharCode(92)).join("/");
|
|
@@ -176,7 +192,7 @@ process.stdin.on("end", () => {
|
|
|
176
192
|
const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
|
|
177
193
|
if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
178
194
|
const parts = Array.isArray(response?.content) ? response.content : [];
|
|
179
|
-
recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))));
|
|
195
|
+
recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
|
|
180
196
|
process.exit(0);
|
|
181
197
|
}
|
|
182
198
|
if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
@@ -213,7 +229,7 @@ process.stdin.on("end", () => {
|
|
|
213
229
|
.filter((l) => l.includes("score=") && l.includes("] "))
|
|
214
230
|
.map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
|
|
215
231
|
.filter(Boolean);
|
|
216
|
-
recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))));
|
|
232
|
+
recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
|
|
217
233
|
process.exit(0);
|
|
218
234
|
}
|
|
219
235
|
// gh prints "merged pull request #N" only on a TTY: under a tool call the
|
|
@@ -275,6 +291,8 @@ const STOP_BODY = `#!/usr/bin/env node
|
|
|
275
291
|
// one with its remediation). Fails OPEN on corrupt state, git errors, or
|
|
276
292
|
// after 3 unresolved blocks — a sentinel bug never bricks the session — and
|
|
277
293
|
// the fail-open is recorded in the state. \`--status\` prints, never blocks.
|
|
294
|
+
import console from "node:console";
|
|
295
|
+
import process from "node:process";
|
|
278
296
|
import { spawnSync } from "node:child_process";
|
|
279
297
|
import { readFileSync } from "node:fs";
|
|
280
298
|
import { homedir } from "node:os";
|
|
@@ -382,6 +400,8 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
382
400
|
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
383
401
|
// remediation); read-only tools are never wired here; every honored escape
|
|
384
402
|
// is recorded as an auditable event. Fails OPEN on anything unexpected.
|
|
403
|
+
import console from "node:console";
|
|
404
|
+
import process from "node:process";
|
|
385
405
|
import { dirname, join, relative, resolve } from "node:path";
|
|
386
406
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
387
407
|
import { spawnSync } from "node:child_process";
|
|
@@ -705,7 +725,7 @@ process.stdin.on("end", () => {
|
|
|
705
725
|
// LITERALES (tambien dentro de sustituciones) los escanea el bucle
|
|
706
726
|
// de tokens de abajo; el residuo (expansion anidada en segmentos
|
|
707
727
|
// runner) queda documentado como en la regla de ficheros PROTECTED.
|
|
708
|
-
if (/>{1,2}[ \\t]*["']?[
|
|
728
|
+
if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
|
|
709
729
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
710
730
|
else {
|
|
711
731
|
console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
@@ -780,7 +800,7 @@ process.stdin.on("end", () => {
|
|
|
780
800
|
}
|
|
781
801
|
const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
|
|
782
802
|
const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
|
|
783
|
-
if (!segs.every((s) => !/[
|
|
803
|
+
if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
784
804
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
785
805
|
else {
|
|
786
806
|
console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
@@ -890,7 +910,9 @@ process.stdin.on("end", () => {
|
|
|
890
910
|
const fresh = !existsSync(String(file));
|
|
891
911
|
// A path outside this tree is another lane's business (the lane guard
|
|
892
912
|
// above rules on it) and the ledger only holds in-repo paths.
|
|
893
|
-
|
|
913
|
+
// KJC-BUG-0190: with nothing indexed, no query can cover anything —
|
|
914
|
+
// the gate asks for kj rag index instead of denying the impossible.
|
|
915
|
+
const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0) || rs.rag_index_empty === true;
|
|
894
916
|
if (!covered) {
|
|
895
917
|
if (escOn("KJ_ALLOW_NO_RAG")) {
|
|
896
918
|
// Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
|
|
@@ -28,7 +28,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
|
|
|
28
28
|
const why = !gh || !email ? "no active gh session or git email" : "non-interactive run";
|
|
29
29
|
const pending = `identity not declared for this clone (${why}) — run: ${hint}`;
|
|
30
30
|
logger?.warn?.(`kj identity: ${pending}`);
|
|
31
|
-
return { declared: false, identity: null, pending };
|
|
31
|
+
return { declared: false, identity: null, pending, command: hint };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const wizard = (deps.makeWizard || createWizard)();
|
|
@@ -37,7 +37,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
|
|
|
37
37
|
if (!ok) {
|
|
38
38
|
const pending = `identity not declared (declined) — run: ${hint}`;
|
|
39
39
|
logger?.warn?.(`kj identity: ${pending}`);
|
|
40
|
-
return { declared: false, identity: null, pending };
|
|
40
|
+
return { declared: false, identity: null, pending, command: hint };
|
|
41
41
|
}
|
|
42
42
|
} finally {
|
|
43
43
|
wizard.close();
|
|
@@ -112,6 +112,24 @@ export function detectNewHomeEntries(before, projectDir) {
|
|
|
112
112
|
* @param {string|null|undefined} transcript — coder's full output text
|
|
113
113
|
* @returns {string[]} filtered leaks (the subset attributable to coder)
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* KJC-BUG-0180 — attribution is not severity. `verifyLeaksAgainstTranscript`
|
|
117
|
+
* stays strict on purpose: with no transcript it returns everything. But
|
|
118
|
+
* "the snapshot changed and there is NO evidence the coder did it" is a
|
|
119
|
+
* different fact from "the coder wrote there", and only the second one
|
|
120
|
+
* should kill the run. The flaky CI failure was exactly this: other test
|
|
121
|
+
* files writing under $HOME during the iteration window, an empty
|
|
122
|
+
* transcript from a mocked coder, and the whole flow failing and retrying.
|
|
123
|
+
*
|
|
124
|
+
* @returns {{attributed: string[], unattributed: string[]}} — `attributed`
|
|
125
|
+
* fails the run; `unattributed` is reported and the work goes on.
|
|
126
|
+
*/
|
|
127
|
+
export function splitLeakEvidence(leaks, transcript) {
|
|
128
|
+
const hasTranscript = typeof transcript === "string" && transcript.length > 0;
|
|
129
|
+
const found = verifyLeaksAgainstTranscript(leaks, transcript);
|
|
130
|
+
return hasTranscript ? { attributed: found, unattributed: [] } : { attributed: [], unattributed: found };
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
export function verifyLeaksAgainstTranscript(leaks, transcript) {
|
|
116
134
|
if (!Array.isArray(leaks) || leaks.length === 0) return [];
|
|
117
135
|
if (!transcript || typeof transcript !== "string" || transcript.length === 0) {
|
|
@@ -20,7 +20,7 @@ import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
|
20
20
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
21
21
|
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
22
22
|
import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
|
|
23
|
-
import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage,
|
|
23
|
+
import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, splitLeakEvidence, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
|
|
24
24
|
|
|
25
25
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
|
|
26
26
|
logger.setContext({ iteration, stage: "coder" });
|
|
@@ -186,9 +186,14 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
186
186
|
// detected entries against the coder's transcript: only flag those
|
|
187
187
|
// the coder demonstrably referenced.
|
|
188
188
|
const coderTranscript = coderExecResult.result?.output || "";
|
|
189
|
-
|
|
189
|
+
// KJC-BUG-0180: only what the transcript ATTRIBUTES to the coder fails the
|
|
190
|
+
// run. Without a transcript there is no evidence it wrote anything, so the
|
|
191
|
+
// finding is reported and the work goes on — a concurrent host-side write
|
|
192
|
+
// under $HOME used to kill the whole flow.
|
|
193
|
+
const { attributed: layer1Leaks, unattributed } = splitLeakEvidence(candidateLeaks, coderTranscript);
|
|
190
194
|
if (candidateLeaks.length > 0 && layer1Leaks.length === 0) {
|
|
191
|
-
|
|
195
|
+
const why = unattributed.length > 0 ? "no coder transcript to attribute them" : "none referenced in the coder transcript";
|
|
196
|
+
logger.warn(`fs-leak-detector: ${candidateLeaks.length} new $HOME entr(y/ies) detected but ${why} — likely a concurrent host-side write, not flagging (#546).`);
|
|
192
197
|
}
|
|
193
198
|
// Layer 2 (BUG-0032): scan the transcript for `cd <abs-out-of-project> && <write-cmd>`
|
|
194
199
|
// patterns the snapshot-diff misses (e.g. when target dir pre-existed).
|
package/src/privacy/scan.js
CHANGED
|
@@ -59,6 +59,17 @@ const SECRET_HEURISTICS = [
|
|
|
59
59
|
// are blanked before redactPII and COUNTED, so a clean result can say
|
|
60
60
|
// "nothing found" or "found and discarded by context" — never the same thing.
|
|
61
61
|
// The personal denylist runs before this: the user's own datum always blocks.
|
|
62
|
+
// KJC-BUG-0195 — a Google key inside a Firebase web config is public by
|
|
63
|
+
// design: it names the project, and what protects the data are the Firestore
|
|
64
|
+
// rules. It still BLOCKS (the shape alone cannot prove which key it is, and a
|
|
65
|
+
// server key next to it would ride along), but the finding stops being a dead
|
|
66
|
+
// end: it names the declared way out, the allowlist, and what actually
|
|
67
|
+
// protects that key. A gate with no exit teaches people to skip the gate.
|
|
68
|
+
// Markers kept unambiguous on purpose (codex caught projectId being generic
|
|
69
|
+
// enough to appear next to a server key): only Firebase-specific ones count.
|
|
70
|
+
const FIREBASE_WEB_MARKERS = /firebaseConfig|authDomain|firebasestorage|firestore\.googleapis\.com|firebaseapp\.com/;
|
|
71
|
+
const FIREBASE_KEY_HINT = "si es la clave web de Firebase (publica por diseno): declarala en el allow de ~/.karajan/privacy.yml y restringela por dominio en la consola de Google";
|
|
72
|
+
|
|
62
73
|
const CONTEXT_DISCARDS = [
|
|
63
74
|
{ type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
|
|
64
75
|
{ type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
|
|
@@ -71,6 +82,7 @@ const CONTEXT_DISCARDS = [
|
|
|
71
82
|
export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
|
|
72
83
|
const findings = [];
|
|
73
84
|
let discarded = 0;
|
|
85
|
+
const firebaseWeb = FIREBASE_WEB_MARKERS.test(String(text));
|
|
74
86
|
String(text).split("\n").forEach((line, i) => {
|
|
75
87
|
let probe = line;
|
|
76
88
|
for (const a of list.allow) probe = probe.split(a).join(" ");
|
|
@@ -85,7 +97,8 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
85
97
|
re.lastIndex = 0;
|
|
86
98
|
let m;
|
|
87
99
|
while ((m = re.exec(probe)) !== null) {
|
|
88
|
-
|
|
100
|
+
const hint = type === "google-key" && firebaseWeb ? ` — ${FIREBASE_KEY_HINT}` : "";
|
|
101
|
+
findings.push({ severity: "block", type, source, line: i + 1, masked: `${maskValue(m[0])}${hint}` });
|
|
89
102
|
probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
|
|
90
103
|
re.lastIndex = m.index + m[0].length;
|
|
91
104
|
}
|
|
@@ -43,9 +43,14 @@ export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles =
|
|
|
43
43
|
const ignored = Object.keys(env || {}).filter((k) => k.startsWith("KJ_ALLOW_") && env[k] === "1");
|
|
44
44
|
const overrideNote = ignored.length > 0 ? ` (${ignored.join(", ")} is not honoured here)` : "";
|
|
45
45
|
const head = `The RAG must have answered about code before it is reviewed (${sources.length} staged source${sources.length === 1 ? "" : "s"})${overrideNote}`;
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
46
|
+
// KJC-BUG-0192: no Sentinel in this tree used to PASS, which made "do not
|
|
47
|
+
// harden" the comfortable way around the gate. Nobody recording the session
|
|
48
|
+
// is a reason to install the harness, or to ask for a grant, never a silent
|
|
49
|
+
// exemption: the proof of the method cannot depend on the project choosing
|
|
50
|
+
// to install whoever records it.
|
|
51
|
+
if (ledger?.harness === false) {
|
|
52
|
+
return { ok: false, mode: "block", sources, reason: `${head} — no Sentinel harness in this tree, so no session ledger can exist: install it with \`kj harden\`. ${GRANT_HINT}` };
|
|
53
|
+
}
|
|
49
54
|
// A harness that does not match the installed kj (edited, emptied, or older
|
|
50
55
|
// than the ledger) cannot vouch for anything: fail closed, like the tamper
|
|
51
56
|
// check does, until the human regenerates it.
|
|
@@ -82,8 +87,12 @@ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness =
|
|
|
82
87
|
if (sources.length === 0) return { ok: true, mode: "docs-only" };
|
|
83
88
|
const grant = liveGrant(standingExceptions, now);
|
|
84
89
|
if (grant) return { ok: true, mode: "granted", grant, sources };
|
|
85
|
-
if (!harness) return { ok: true, mode: "no-harness", sources };
|
|
86
90
|
const head = `The RAG must have answered about code before it is committed (${sources.length} staged source${sources.length === 1 ? "" : "s"})`;
|
|
91
|
+
// KJC-BUG-0192: same rule on the --check side, so the commit gate cannot be
|
|
92
|
+
// opened by removing the supervisor either.
|
|
93
|
+
if (!harness) {
|
|
94
|
+
return { ok: false, mode: "block", sources, reason: `${head} — no Sentinel harness in this tree, so no session ledger can exist: install it with \`kj harden\`. ${GRANT_HINT}` };
|
|
95
|
+
}
|
|
87
96
|
if (!verified) {
|
|
88
97
|
return { ok: false, mode: "block", sources, reason: `${head} — the Sentinel harness does not match the installed kj (${mismatched.join(", ") || "scripts missing"}): the human runs \`kj harden\` to regenerate it. ${GRANT_HINT}` };
|
|
89
98
|
}
|
|
@@ -64,12 +64,15 @@ export function collectPending(results, { tty = Boolean(process.stdin.isTTY), ye
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** The PENDING USER ACTION block: exact commands for THIS OS, then wait. */
|
|
67
|
-
export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools" } = {}) {
|
|
67
|
+
export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools", why = null } = {}) {
|
|
68
68
|
const lines = [
|
|
69
69
|
"════ PENDING USER ACTION ════════════════════════════════════════",
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// `why` names the real cause when it is not an install (KJC-BUG-0188:
|
|
71
|
+
// an undeclared identity needs the human, not a package manager).
|
|
72
|
+
...(why
|
|
73
|
+
? [`kj could not finish by itself: ${why}.`]
|
|
74
|
+
: ["kj could not finish this install by itself (sudo or a platform", "installer is required)."]),
|
|
75
|
+
"Karajan needs a COMPLETE environment — do not continue degraded.",
|
|
73
76
|
"",
|
|
74
77
|
"Run in YOUR terminal:",
|
|
75
78
|
"",
|