karajan-code 4.38.0 → 4.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/ai-trash/src/destructive-parser.js +34 -5
- package/packages/ai-trash/src/hook.js +28 -6
- package/src/checks/method.js +1 -1
- package/src/checks/release-check.js +3 -1
- package/src/cli/register-pipeline.js +2 -1
- package/src/commands/review-gate.js +21 -22
- package/src/harden/harness-hooks.js +7 -85
- package/src/harden/hook-templates.js +6 -5
- package/src/harden/sentinel/sentinel-discard.mjs +29 -2
- package/src/harden/sentinel/sentinel-shell.mjs +17 -2
- package/src/harden/sentinel-hooks.js +88 -192
- package/src/policy/supervisor-verify.js +8 -4
- package/src/review/card-first.js +3 -5
- package/src/review/policy-gate.js +4 -6
- package/src/review/sonar-pregate.js +3 -3
- package/src/review/tests-with-code.js +3 -6
- package/src/sonar/api.js +21 -0
- package/src/sonar/scanner.js +22 -3
package/package.json
CHANGED
|
@@ -35,14 +35,43 @@ function classifyMv(tokens) {
|
|
|
35
35
|
return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// KJC-BUG-0240 (#1886): checkout and restore drop uncommitted work file by file,
|
|
39
|
+
// and a bundle keeps commits only, so the named files are kept as files and a
|
|
40
|
+
// discard of the whole tree marks it (worktree) for the hook to keep every dirty file.
|
|
41
|
+
function classifyDiscard(tokens, sub) {
|
|
42
|
+
const args = tokens.slice(2);
|
|
43
|
+
const has = (...flags) => flags.some((f) => args.includes(f));
|
|
44
|
+
const SAFE = { destructive: false, kind: "git-safe", paths: [] };
|
|
45
|
+
if (sub === "restore" && has("--staged", "-S") && !has("--worktree", "-W")) return { ...SAFE, reason: "restore --staged only unstages" };
|
|
46
|
+
// -B resets an existing branch: its commits need the bundle.
|
|
47
|
+
if (sub === "checkout" && has("-B")) return { destructive: true, kind: "git-checkout-reset-branch", bundle: true, paths: [], reason: "git checkout -B resets an existing branch" };
|
|
48
|
+
if (sub === "checkout" && has("-b", "--orphan")) return { ...SAFE, reason: "checkout -b creates a branch" };
|
|
49
|
+
const dd = args.indexOf("--");
|
|
50
|
+
const named = [];
|
|
51
|
+
let sourceValue = false;
|
|
52
|
+
args.forEach((a, i) => {
|
|
53
|
+
if (sourceValue) sourceValue = false;
|
|
54
|
+
else if (a === "-s" || a === "--source") sourceValue = true;
|
|
55
|
+
else if ((dd >= 0 && i > dd) || !a.startsWith("-")) named.push(a);
|
|
56
|
+
});
|
|
57
|
+
if (!named.length && !has("-f", "--force")) return { ...SAFE, reason: `${sub} with nothing to discard` };
|
|
58
|
+
const whole = has("-f", "--force") || named.includes(".") || named.includes(":/");
|
|
59
|
+
return { destructive: true, kind: `git-${sub}-discard`, paths: named.filter((p) => p !== "." && p !== ":/"), ...(whole ? { worktree: "tracked" } : {}), reason: `git ${sub} discards local edits` };
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
function classifyGit(tokens) {
|
|
39
63
|
const sub = tokens[1];
|
|
40
64
|
const has = (...flags) => flags.some((f) => tokens.includes(f));
|
|
41
|
-
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", paths: [], reason: "git reset --hard drops working tree" };
|
|
42
|
-
if (sub === "clean" &&
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", bundle: true, worktree: "tracked", paths: [], reason: "git reset --hard drops working tree" };
|
|
66
|
+
if (sub === "clean" && tokens.some((t) => t === "--force" || /^-[a-zA-Z]*f/.test(t))) {
|
|
67
|
+
// -x / -X also remove ignored files.
|
|
68
|
+
const ignored = tokens.some((t) => /^-[a-zA-Z]*[xX]/.test(t));
|
|
69
|
+
return { destructive: true, kind: "git-clean", bundle: true, worktree: ignored ? "untracked+ignored" : "untracked", paths: [], reason: "git clean -f removes untracked files" };
|
|
70
|
+
}
|
|
71
|
+
if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", bundle: true, paths: [], reason: "git branch -D drops unmerged branch" };
|
|
72
|
+
if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", bundle: true, paths: [], reason: "git push --force rewrites remote history" };
|
|
73
|
+
if (sub === "checkout" || sub === "restore") return classifyDiscard(tokens, sub);
|
|
74
|
+
if (sub === "switch" && has("-f", "--force", "--discard-changes")) return { destructive: true, kind: "git-switch-discard", worktree: "tracked", paths: [], reason: "git switch --discard-changes drops local edits" };
|
|
46
75
|
return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// throws, deny the op so Claude never destroys without a recovery copy.
|
|
5
5
|
|
|
6
6
|
import { promises as fs } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
7
8
|
import { isAbsolute, resolve } from "node:path";
|
|
8
9
|
import process from "node:process";
|
|
9
10
|
import { classifyCommand } from "./destructive-parser.js";
|
|
@@ -47,6 +48,29 @@ async function snapshotExistingPaths(root, paths, cwd, command) {
|
|
|
47
48
|
return snapshots;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// KJC-BUG-0240 (#1886): the dirty files a whole-tree discard would drop, kept as
|
|
52
|
+
// files (a bundle keeps commits only). `which`: "tracked" changes, "untracked"
|
|
53
|
+
// files, or "untracked+ignored" (git clean -x/-X removes ignored files too).
|
|
54
|
+
async function snapshotDirtyFiles(root, cwd, command, which) {
|
|
55
|
+
if (!cwd) return [];
|
|
56
|
+
const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
57
|
+
if (top.status !== 0) return [];
|
|
58
|
+
const withIgnored = which === "untracked+ignored";
|
|
59
|
+
// traditional + all: every file inside an ignored directory is listed one by one.
|
|
60
|
+
const st = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all", ...(withIgnored ? ["--ignored=traditional"] : [])], { encoding: "utf8" });
|
|
61
|
+
if (st.status !== 0) throw new Error(`git status failed: ${st.stderr.trim()}`);
|
|
62
|
+
const wanted = (rec) => (which === "tracked" ? !rec.startsWith("??") && !rec.startsWith("!!") : rec.startsWith("??") || (withIgnored && rec.startsWith("!!")));
|
|
63
|
+
const files = [];
|
|
64
|
+
let renameSource = false; // a rename or copy record is followed by its source path
|
|
65
|
+
for (const rec of st.stdout.split("\0")) {
|
|
66
|
+
if (renameSource) { renameSource = false; continue; }
|
|
67
|
+
if (rec.length < 4) continue;
|
|
68
|
+
if (wanted(rec)) files.push(rec.slice(3));
|
|
69
|
+
renameSource = "RC".includes(rec[0]);
|
|
70
|
+
}
|
|
71
|
+
return snapshotExistingPaths(root, files, top.stdout.trim(), command);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
async function snapshotGitRepo(root, cwd, command, kind) {
|
|
51
75
|
if (!cwd) return { snapshots: [], skipped: "no cwd" };
|
|
52
76
|
try {
|
|
@@ -86,11 +110,9 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
86
110
|
await assertOwnedByCurrentUser(root);
|
|
87
111
|
let snapshots = [];
|
|
88
112
|
let skipped = null;
|
|
89
|
-
if (verdict.kind
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
snapshots = await snapshotExistingPaths(root, verdict.paths, data.cwd, command);
|
|
93
|
-
}
|
|
113
|
+
if (verdict.bundle) ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
|
|
114
|
+
snapshots.push(...(await snapshotExistingPaths(root, verdict.paths, data.cwd, command)));
|
|
115
|
+
if (verdict.worktree) snapshots.push(...(await snapshotDirtyFiles(root, data.cwd, command, verdict.worktree)));
|
|
94
116
|
if (snapshots.length) {
|
|
95
117
|
const m = await loadManifest(root);
|
|
96
118
|
for (const s of snapshots) m.entries.push(s);
|
|
@@ -106,7 +128,7 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
106
128
|
if (snapshots.length) {
|
|
107
129
|
return reply(
|
|
108
130
|
"allow",
|
|
109
|
-
`ai-trash: snapshotted ${snapshots.length} ${
|
|
131
|
+
`ai-trash: snapshotted ${snapshots.length} ${snapshots.some((s) => s.type === "git-bundle") ? "item(s), git bundle included," : "path(s)"} before ${verdict.kind}`
|
|
110
132
|
);
|
|
111
133
|
}
|
|
112
134
|
return reply(
|
package/src/checks/method.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
36
36
|
const blocks = blocksRes.exitCode === 0
|
|
37
37
|
? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
|
|
38
38
|
: [];
|
|
39
|
-
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files
|
|
39
|
+
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files }).mode !== "pass").length;
|
|
40
40
|
|
|
41
41
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
42
42
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
@@ -34,7 +34,9 @@ async function policyRangeCheck(projectDir) {
|
|
|
34
34
|
// protects the supervisor. `kj review` and `kj policy check` already lift
|
|
35
35
|
// what the sealed provenance backs; without this the release check blocked
|
|
36
36
|
// every version cut after a human `kj harden --commit`, by its own seal.
|
|
37
|
-
|
|
37
|
+
// KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
|
|
38
|
+
// the machine-local guards were verified when the seal was reviewed.
|
|
39
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
|
|
38
40
|
const violations = sup.violations;
|
|
39
41
|
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
40
42
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
@@ -201,7 +201,8 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
201
201
|
program
|
|
202
202
|
.command("solomon")
|
|
203
203
|
.description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
|
|
204
|
-
.
|
|
204
|
+
.option("--position <text>", "Why the brain disagrees with the reviewer")
|
|
205
|
+
.option("--position-file <path>", "Read the position from a file (no quoting to get right)")
|
|
205
206
|
.option("--range <range>", "Arbitrate a git range instead of the staged diff")
|
|
206
207
|
.action(async (flags) => {
|
|
207
208
|
await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
|
|
@@ -42,7 +42,6 @@ async function enforceCardFirst({ config, projectDir }) {
|
|
|
42
42
|
// KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
|
|
43
43
|
// branch-ref alone must never read as a tracker-verified pass.
|
|
44
44
|
if (card.note) console.log(`⚠ card-first: ${card.note}`);
|
|
45
|
-
if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
|
|
46
45
|
if (!card.ok) {
|
|
47
46
|
console.log(`✗ card-first gate: ${card.reason}`);
|
|
48
47
|
process.exitCode = 1;
|
|
@@ -139,6 +138,14 @@ function printVerdict(record) {
|
|
|
139
138
|
*/
|
|
140
139
|
export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
141
140
|
const projectDir = config?.projectDir || process.cwd();
|
|
141
|
+
// KJC-BUG-0260: a position is prose with blanks and slashes; from a file it
|
|
142
|
+
// needs no quoting the lane guard has to tell apart from a path.
|
|
143
|
+
const position = flags.positionFile ? readFileSync(flags.positionFile, "utf8").trim() : flags.position;
|
|
144
|
+
if (!position) {
|
|
145
|
+
console.log("✗ kj solomon: give the brain's position with --position <text> or --position-file <path>");
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return { ruling: "reject", reasoning: "no position given" };
|
|
148
|
+
}
|
|
142
149
|
const diff = await rawDiff(flags.range);
|
|
143
150
|
// KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
|
|
144
151
|
// arbitrable — solomon se niega antes de gastar un token. Y una policy
|
|
@@ -162,7 +169,7 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
|
162
169
|
process.exitCode = 1;
|
|
163
170
|
return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
|
|
164
171
|
}
|
|
165
|
-
const res = await runSolomonArbitration({ diff, position
|
|
172
|
+
const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
|
|
166
173
|
if (res.ruling === "approve") {
|
|
167
174
|
console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
|
|
168
175
|
} else {
|
|
@@ -256,10 +263,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
256
263
|
// partitioning is a decision, and it must never split code from its tests.
|
|
257
264
|
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
|
|
258
265
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
266
|
+
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
267
|
+
if (sizePolicy === "block") {
|
|
268
|
+
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
|
|
263
269
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
264
270
|
process.exitCode = 1;
|
|
265
271
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
@@ -269,7 +275,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
269
275
|
}
|
|
270
276
|
}
|
|
271
277
|
// KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
|
|
272
|
-
// Denylist data rejects (
|
|
278
|
+
// Denylist data rejects with no escape (ADR 0015: false positives go in the allow list); generic PII
|
|
273
279
|
// warns (privacy.generic: "block" hardens). Added lines only — deletions
|
|
274
280
|
// don't publish. In the SEA binary the privacy module is stubbed and
|
|
275
281
|
// throws: the gate degrades with a note instead of crashing.
|
|
@@ -288,25 +294,19 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
288
294
|
for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
|
|
289
295
|
const hardened = config?.privacy?.generic === "block" && warns.length > 0;
|
|
290
296
|
if (blocks.length > 0 || hardened) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
console.log(`✗ privacy gate: ${reason}`);
|
|
297
|
-
process.exitCode = 1;
|
|
298
|
-
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
299
|
-
}
|
|
297
|
+
for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
298
|
+
const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (a confirmed false positive goes in the allow list of ~/.karajan/privacy.yml)`;
|
|
299
|
+
console.log(`✗ privacy gate: ${reason}`);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
300
302
|
}
|
|
301
303
|
} catch (err) {
|
|
302
304
|
// Known degradation: the SEA stub throws its install-from-npm message.
|
|
303
305
|
// Anything else fails CLOSED — a silent skip would defeat the guarantee.
|
|
304
306
|
if (/standalone binary/i.test(err?.message || "")) {
|
|
305
307
|
console.log("⚠ privacy gate unavailable in this build — skipping");
|
|
306
|
-
} else if (process.env.KJ_ALLOW_PII === "1") {
|
|
307
|
-
console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
308
308
|
} else {
|
|
309
|
-
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked
|
|
309
|
+
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked`;
|
|
310
310
|
console.log(`✗ ${reason}`);
|
|
311
311
|
process.exitCode = 1;
|
|
312
312
|
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
@@ -330,9 +330,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
330
330
|
|
|
331
331
|
// KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
|
|
332
332
|
// --staged Y en --check — el pre-commit hereda los dientes sin regenerar
|
|
333
|
-
// hooks. enforcement=warn avisa; deny cierra
|
|
334
|
-
//
|
|
335
|
-
// del diff); class=security cierra sin escape y sin arbitraje.
|
|
333
|
+
// hooks. enforcement=warn avisa; deny cierra sin excepción por diff (ADR
|
|
334
|
+
// 0015); class=security cierra además sin arbitraje.
|
|
336
335
|
// KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
|
|
337
336
|
// del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
|
|
338
337
|
// regla en dos lineas seguidas del mismo comando.
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Field case 2026-08-02: text rules were not enough — the environment must
|
|
4
4
|
* impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
|
|
5
5
|
* it into the project's `.claude/settings.json` (merged, never clobbered):
|
|
6
|
-
* Write over an existing file blocks ("use Edit",
|
|
7
|
-
* Bash
|
|
6
|
+
* Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
|
|
7
|
+
* Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
|
|
8
|
+
* replaced the JSON-reserialization guard that lived here.
|
|
8
9
|
* Claude-only v1 — the abstraction arrives with the second host that
|
|
9
10
|
* supports tool hooks.
|
|
10
11
|
*/
|
|
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
|
|
|
19
20
|
// so a gate bug never bricks the session.
|
|
20
21
|
import console from "node:console";
|
|
21
22
|
import process from "node:process";
|
|
22
|
-
import { existsSync
|
|
23
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
// El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
|
|
26
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
27
24
|
let raw = "";
|
|
28
25
|
process.stdin.on("data", (d) => { raw += d; });
|
|
29
26
|
process.stdin.on("end", () => {
|
|
30
27
|
try {
|
|
31
28
|
const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
32
|
-
if (tool === "Write" &&
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.exit(2);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (tool === "Bash") {
|
|
39
|
-
const cmd = String(input.command || "");
|
|
40
|
-
// KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
|
|
41
|
-
// resto de guardias. Mirando solo process.env el agente no tenia salida:
|
|
42
|
-
// un export dentro del comando jamas llega a este proceso.
|
|
43
|
-
// Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
|
|
44
|
-
// aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
|
|
45
|
-
// un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
|
|
46
|
-
const toks = cmd.trim().split(/\\s+/);
|
|
47
|
-
let i = 0;
|
|
48
|
-
let named = false;
|
|
49
|
-
while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
|
|
50
|
-
if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
|
|
51
|
-
i += 1;
|
|
52
|
-
}
|
|
53
|
-
// Una asignacion delante solo alcanza a SU comando, asi que el escape del
|
|
54
|
-
// comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
|
|
55
|
-
// o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
|
|
56
|
-
// que va entre comillas no encadena nada, asi que no cuenta.
|
|
57
|
-
// Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
|
|
58
|
-
// hereda la asignacion (catch de la review). Mismo criterio que el resto
|
|
59
|
-
// de guardias del Sentinel.
|
|
60
|
-
// Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
|
|
61
|
-
// encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
|
|
62
|
-
const noSingle = cmd.replaceAll(/'[^']*'/g, "");
|
|
63
|
-
const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
|
|
64
|
-
const bare = noSingle.replaceAll(/"[^"]*"/g, "");
|
|
65
|
-
const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
|
|
66
|
-
const cmdEscape = named && simple && i < toks.length;
|
|
67
|
-
const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
|
|
68
|
-
// Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
|
|
69
|
-
if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
|
|
70
|
-
const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
|
|
71
|
-
// KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
|
|
72
|
-
// temporal del scratchpad no es asunto de este guard, y bloquearlo
|
|
73
|
-
// ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
|
|
74
|
-
const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
|
|
75
|
-
// Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
|
|
76
|
-
// fuera del arbol. Una relativa depende del cwd del shell, que este hook
|
|
77
|
-
// no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
|
|
78
|
-
// dentro del repo (catch de la review), asi que se sigue vigilando.
|
|
79
|
-
// Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
|
|
80
|
-
// (catch de la review). Se resuelve el padre real antes de clasificar, y
|
|
81
|
-
// si no se puede resolver, se vigila.
|
|
82
|
-
// Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
|
|
83
|
-
// <repo>/..config.json esta dentro (catch de la review).
|
|
84
|
-
const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
|
|
85
|
-
const outsideForSure = (t) => {
|
|
86
|
-
if (!t.startsWith("/")) return false;
|
|
87
|
-
const abs = resolve(t);
|
|
88
|
-
try {
|
|
89
|
-
const root = realpathSync(ROOT);
|
|
90
|
-
// El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
|
|
91
|
-
// asi que se resuelve entero cuando existe; si aun no existe, manda su
|
|
92
|
-
// directorio real.
|
|
93
|
-
if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
|
|
94
|
-
const parts = abs.split("/");
|
|
95
|
-
const name = parts.pop();
|
|
96
|
-
return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
// Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
|
|
102
|
-
// deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
|
|
103
|
-
// de la review). Ante la duda se vigila.
|
|
104
|
-
const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
|
|
105
|
-
const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
|
|
106
|
-
if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
|
|
107
|
-
console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
|
|
108
|
-
process.exit(2);
|
|
109
|
-
}
|
|
29
|
+
if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
|
|
30
|
+
console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
|
|
31
|
+
process.exit(2);
|
|
110
32
|
}
|
|
111
33
|
} catch { /* fail open */ }
|
|
112
34
|
process.exit(0);
|
|
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
|
|
|
62
62
|
return [
|
|
63
63
|
"# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
|
|
64
64
|
"# the bootstrap commit is exempt, there is no commit to branch from yet.",
|
|
65
|
-
|
|
65
|
+
"# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
|
|
66
|
+
"if git rev-parse --verify HEAD >/dev/null 2>&1; then",
|
|
66
67
|
' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
67
68
|
` if [ "$current_branch" = "${baseBranch}" ]; then`,
|
|
68
|
-
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR
|
|
69
|
+
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
|
|
69
70
|
" fi",
|
|
70
71
|
"fi",
|
|
71
72
|
];
|
|
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
|
|
|
78
79
|
function identityGuard(hook) {
|
|
79
80
|
const head = [
|
|
80
81
|
"# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
|
|
81
|
-
|
|
82
|
+
"if [ -f .karajan/identity.local.yml ]; then",
|
|
82
83
|
];
|
|
83
84
|
const tail = [
|
|
84
85
|
"elif [ ! -f .karajan/identity.local.yml ]; then",
|
|
@@ -92,7 +93,7 @@ function identityGuard(hook) {
|
|
|
92
93
|
" kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
93
94
|
" kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
94
95
|
' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
|
|
95
|
-
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show
|
|
96
|
+
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
|
|
96
97
|
" fi",
|
|
97
98
|
...tail,
|
|
98
99
|
];
|
|
@@ -103,7 +104,7 @@ function identityGuard(hook) {
|
|
|
103
104
|
' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
|
|
104
105
|
" kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
|
|
105
106
|
' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
|
|
106
|
-
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh
|
|
107
|
+
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
|
|
107
108
|
" fi",
|
|
108
109
|
...tail,
|
|
109
110
|
];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ADR 0014). An agent does not discard changes it did not make. Copied byte for
|
|
3
3
|
// byte into .karajan/harness; `root` is the project root, injected.
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { relative, resolve } from "node:path";
|
|
5
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { headIndex, shortOpts } from "./sentinel-shell.mjs";
|
|
8
8
|
|
|
@@ -58,6 +58,30 @@ export const discardOf = (words, root) => {
|
|
|
58
58
|
return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* KJC-BUG-0261: the tracked files an `rm` or `git rm` removes, relative to root.
|
|
63
|
+
* Removing a file is the session touching it, so its state is noted before it
|
|
64
|
+
* goes and restoring it later is not taken for someone else's change.
|
|
65
|
+
* @param {string[]} words
|
|
66
|
+
* @param {string} root
|
|
67
|
+
* @param {string} cwd where relative paths resolve (the session's cwd)
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export const removedFiles = (words, root, cwd = process.cwd()) => {
|
|
71
|
+
const i = headIndex(words, ["rm", "git"]);
|
|
72
|
+
const head = words[i]?.split("/").at(-1);
|
|
73
|
+
const isGitRm = head === "git" && words[i + 1] === "rm";
|
|
74
|
+
if (head !== "rm" && !isGitRm) return [];
|
|
75
|
+
const args = words.slice(i + (isGitRm ? 2 : 1));
|
|
76
|
+
const dd = args.indexOf("--");
|
|
77
|
+
const named = dd < 0 ? args.filter((a) => !a.startsWith("-")) : [...args.slice(0, dd).filter((a) => !a.startsWith("-")), ...args.slice(dd + 1)];
|
|
78
|
+
const rels = named.map((p) => relative(root, resolve(cwd, p))).filter((r) => r && !r.startsWith("..") && !isAbsolute(r));
|
|
79
|
+
if (rels.length === 0) return [];
|
|
80
|
+
// ls-files expands a directory (rm -r dir) into the tracked files it holds.
|
|
81
|
+
const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--", ...rels], { encoding: "utf8" });
|
|
82
|
+
return r.status === 0 ? String(r.stdout).split("\0").filter(Boolean) : [];
|
|
83
|
+
};
|
|
84
|
+
|
|
61
85
|
/**
|
|
62
86
|
* Files the discard would lose that the session did not own: dirty now and not
|
|
63
87
|
* clean at first touch. Anything unreadable, or another repo, is foreign.
|
|
@@ -84,7 +108,10 @@ export const foreignLost = (d, touch, root) => {
|
|
|
84
108
|
}
|
|
85
109
|
// Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
|
|
86
110
|
// be proven safe; git would refuse to check it out anyway.
|
|
87
|
-
if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0)
|
|
111
|
+
if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) {
|
|
112
|
+
// KJC-BUG-0261: what the session removed with git rm left the index, and it is still its own.
|
|
113
|
+
return d.paths.map((p) => relative(root, resolve(d.cwd, p))).every(own) ? [] : [`(ruta no resoluble: ${d.paths.join(" ")})`];
|
|
114
|
+
}
|
|
88
115
|
const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
|
|
89
116
|
if (r.status !== 0) return ["(git status fallo)"];
|
|
90
117
|
const files = [];
|
|
@@ -86,10 +86,25 @@ export const stripInertQuotes = (cmd) => {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
// Options whose value is prose, never a path (gh, git, kj).
|
|
89
|
-
const
|
|
89
|
+
const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position"]);
|
|
90
|
+
const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
90
91
|
|
|
91
92
|
/** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
|
|
92
|
-
export const stripTextOptionValues = (cmd) => cmd.replace(
|
|
93
|
+
export const stripTextOptionValues = (cmd) => cmd.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* KJC-BUG-0245: the values of the named options, read by the shell reader, so a
|
|
97
|
+
* `;`, `&&` or `|` right after a path ends the word instead of joining it.
|
|
98
|
+
* Covers `--opt value` and `--opt=value`.
|
|
99
|
+
* @param {string} cmd
|
|
100
|
+
* @param {string[]} names
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) => words.flatMap((w, i) => {
|
|
104
|
+
if (names.includes(w) && i + 1 < words.length) return [words[i + 1]];
|
|
105
|
+
const eq = w.indexOf("=");
|
|
106
|
+
return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
|
|
107
|
+
}));
|
|
93
108
|
|
|
94
109
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
95
110
|
export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
|
|
@@ -28,8 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
|
|
|
28
28
|
|
|
29
29
|
const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
|
|
30
30
|
// Single source for every sentinel script: state, branch, classification,
|
|
31
|
-
//
|
|
32
|
-
import console from "node:console";
|
|
31
|
+
// and violations.
|
|
33
32
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
34
33
|
import { execSync, spawnSync } from "node:child_process";
|
|
35
34
|
import { dirname, join } from "node:path";
|
|
@@ -81,8 +80,20 @@ export const bootstrapPhase = () => {
|
|
|
81
80
|
try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
|
|
82
81
|
catch { return true; }
|
|
83
82
|
};
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
// KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
|
|
84
|
+
// open. kj decides with that checkable fact, the same answer for commit and push
|
|
85
|
+
// (the Stop gate still holds the turn: the board owes the move); when kj does not
|
|
86
|
+
// answer, every pending move blocks.
|
|
87
|
+
export const boardGate = (sid, pend) => {
|
|
88
|
+
if (!pend.length) return { blocking: [], carried: [] };
|
|
89
|
+
const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
90
|
+
if (!bg.error && (bg.status === 0 || bg.status === 2)) {
|
|
91
|
+
try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [] }; } catch { /* unreadable: as always */ }
|
|
92
|
+
}
|
|
93
|
+
return { blocking: pend, carried: [] };
|
|
94
|
+
};
|
|
95
|
+
export const violations = (s, branch, pending = pendingMoves(s)) => {
|
|
96
|
+
const v = pending.map(pendingText);
|
|
86
97
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
87
98
|
if (bootstrapPhase()) return v;
|
|
88
99
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
@@ -105,26 +116,6 @@ const sessionAddsCode = (s) => {
|
|
|
105
116
|
return a === "-" || Number(a) > 0;
|
|
106
117
|
});
|
|
107
118
|
};
|
|
108
|
-
export const recordEscape = (sid, escape, tool) => {
|
|
109
|
-
const state = load();
|
|
110
|
-
const s = session(state, sid);
|
|
111
|
-
s.at = Date.now();
|
|
112
|
-
if (!s.escapes.includes(escape)) s.escapes.push(escape);
|
|
113
|
-
(state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
|
|
114
|
-
save(state);
|
|
115
|
-
// GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
|
|
116
|
-
// decision log hash-encadenado (kj policy seal), no solo en este estado.
|
|
117
|
-
// Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
|
|
118
|
-
// kj no carga, que es cuando mas se usa el escape (restaurar con git).
|
|
119
|
-
try {
|
|
120
|
-
const args = ["policy", "seal", "--escape", escape];
|
|
121
|
-
if (tool) args.push("--tool", String(tool));
|
|
122
|
-
const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
|
|
123
|
-
if (r.error || r.status !== 0) console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json" + doc("escapes"));
|
|
124
|
-
} catch (e) {
|
|
125
|
-
console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message + doc("escapes"));
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
119
|
`;
|
|
129
120
|
|
|
130
121
|
const POST_BODY = `#!/usr/bin/env node
|
|
@@ -137,7 +128,6 @@ import { relative } from "node:path";
|
|
|
137
128
|
import { spawnSync } from "node:child_process";
|
|
138
129
|
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
139
130
|
import { remindersFor } from "./sentinel-reminders.mjs";
|
|
140
|
-
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", "KJ_ALLOW_NO_VERIFY"];
|
|
141
131
|
let raw = "";
|
|
142
132
|
process.stdin.on("data", (d) => { raw += d; });
|
|
143
133
|
process.stdin.on("end", () => {
|
|
@@ -293,11 +283,6 @@ process.stdin.on("end", () => {
|
|
|
293
283
|
const rel = relative(ROOT, file).replaceAll("\\\\", "/");
|
|
294
284
|
const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
|
|
295
285
|
if (bucket && !bucket.includes(rel)) bucket.push(rel);
|
|
296
|
-
for (const e of ESCAPES)
|
|
297
|
-
if (process.env[e] === "1" && !s.escapes.includes(e)) {
|
|
298
|
-
s.escapes.push(e);
|
|
299
|
-
(state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
|
|
300
|
-
}
|
|
301
286
|
// KJC-TSK-0910: the branch size while it is written, with the CI budget
|
|
302
287
|
// (kj pr-size), said once per threshold crossed. Context, never a block.
|
|
303
288
|
let sizeNote = null;
|
|
@@ -417,7 +402,6 @@ process.stdin.on("end", () => {
|
|
|
417
402
|
s.blocks = 0;
|
|
418
403
|
const notes = [];
|
|
419
404
|
if (claimsNote) notes.push(claimsNote);
|
|
420
|
-
if ((s.escapes || []).length) notes.push("karajan sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
421
405
|
if ((s.closed_cards || []).length) {
|
|
422
406
|
notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
423
407
|
s.closed_cards = [];
|
|
@@ -454,10 +438,10 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
|
454
438
|
import { spawnSync } from "node:child_process";
|
|
455
439
|
import { homedir } from "node:os";
|
|
456
440
|
import { fileURLToPath } from "node:url";
|
|
457
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations,
|
|
441
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
458
442
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
459
|
-
import { shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
460
|
-
import { DISCARD_VERBS, discardOf, foreignLost } from "./sentinel-discard.mjs";
|
|
443
|
+
import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
444
|
+
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
461
445
|
import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
|
|
462
446
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
463
447
|
// KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
|
|
@@ -481,6 +465,21 @@ process.stdin.on("end", () => {
|
|
|
481
465
|
process.on("exit", (code) => {
|
|
482
466
|
if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
|
|
483
467
|
});
|
|
468
|
+
// KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
|
|
469
|
+
// sesion toca un fichero se anota si estaba limpio: solo entonces un
|
|
470
|
+
// descarte posterior pierde nada mas que lo de la sesion. Antes de
|
|
471
|
+
// cualquier escape; git que falla cuenta como sucio (fail-closed).
|
|
472
|
+
const noteFirstTouch = (relT) => {
|
|
473
|
+
const st = load();
|
|
474
|
+
const touch = (session(st, sid).first_touch ||= {});
|
|
475
|
+
// hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
|
|
476
|
+
if (Object.hasOwn(touch, relT)) return;
|
|
477
|
+
// --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
|
|
478
|
+
const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
|
|
479
|
+
const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
|
|
480
|
+
Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
|
|
481
|
+
save(st);
|
|
482
|
+
};
|
|
484
483
|
// Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
|
|
485
484
|
// KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
|
|
486
485
|
// only the human, editing outside it.
|
|
@@ -497,23 +496,10 @@ process.stdin.on("end", () => {
|
|
|
497
496
|
console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
|
|
498
497
|
process.exit(2);
|
|
499
498
|
}
|
|
500
|
-
|
|
501
|
-
// sesion toca un fichero se anota si estaba limpio: solo entonces un
|
|
502
|
-
// descarte posterior pierde nada mas que lo de la sesion. Antes de
|
|
503
|
-
// cualquier escape; git que falla cuenta como sucio (fail-closed).
|
|
504
|
-
if (relT && !relT.startsWith("..") && !relT.startsWith("/")) {
|
|
505
|
-
const st = load();
|
|
506
|
-
const touch = (session(st, sid).first_touch ||= {});
|
|
507
|
-
// hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
|
|
508
|
-
if (!Object.hasOwn(touch, relT)) {
|
|
509
|
-
// --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
|
|
510
|
-
const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
|
|
511
|
-
const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
|
|
512
|
-
Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
|
|
513
|
-
save(st);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
499
|
+
if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
|
|
516
500
|
}
|
|
501
|
+
// KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
|
|
502
|
+
if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
|
|
517
503
|
// Any Bash that NAMES the supervisor's files is denied — a write-verb
|
|
518
504
|
// blocklist is bypassable (cp, dd, one-liners), and reading them is what
|
|
519
505
|
// the Read/Grep tools are for. Shell indirection (variables, globs,
|
|
@@ -536,8 +522,8 @@ process.stdin.on("end", () => {
|
|
|
536
522
|
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)) {
|
|
537
523
|
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;
|
|
538
524
|
let corpus = ghCmd;
|
|
539
|
-
|
|
540
|
-
|
|
525
|
+
// KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
|
|
526
|
+
for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
|
|
541
527
|
try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
|
|
542
528
|
console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
|
|
543
529
|
process.exit(2);
|
|
@@ -598,68 +584,11 @@ process.stdin.on("end", () => {
|
|
|
598
584
|
}
|
|
599
585
|
}
|
|
600
586
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
601
|
-
// KJC-
|
|
602
|
-
//
|
|
603
|
-
// prefijo jamas llegaba a process.env y el escape era inalcanzable
|
|
604
|
-
// (deadlock real del gate de release: la landing solo se pone verde
|
|
605
|
-
// tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
|
|
606
|
-
// escape es una excepcion CONSCIENTE y auditada, no un boundary de
|
|
607
|
-
// seguridad (security sigue sin escape y la autoprotecion corre antes),
|
|
608
|
-
// asi que el TEXTO del comando es fuente valida: solo asignaciones
|
|
609
|
-
// LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
|
|
610
|
-
// sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
|
|
611
|
-
// prefijo shell no alcanzaria a los comandos posteriores (catch de
|
|
612
|
-
// codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
|
|
613
|
-
// escape por texto se rechaza y queda la via env de siempre.
|
|
587
|
+
// ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
|
|
588
|
+
// text is read only by the identity lock below.
|
|
614
589
|
const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
|
|
615
590
|
const ESC_TAB = String.fromCharCode(9);
|
|
616
591
|
const ESC_NL = String.fromCharCode(10);
|
|
617
|
-
const ESC_BT = String.fromCharCode(96);
|
|
618
|
-
const escOn = (name) => {
|
|
619
|
-
if (process.env[name] === "1") return true;
|
|
620
|
-
let found = false;
|
|
621
|
-
let i = 0;
|
|
622
|
-
while (i < CMD_TEXT.length) {
|
|
623
|
-
while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
|
|
624
|
-
const start = i;
|
|
625
|
-
while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
|
|
626
|
-
const tok = CMD_TEXT.slice(start, i);
|
|
627
|
-
const eq = tok.indexOf("=");
|
|
628
|
-
if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
|
|
629
|
-
if (tok === name + "=1") found = true;
|
|
630
|
-
}
|
|
631
|
-
if (!found) return false;
|
|
632
|
-
// KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
|
|
633
|
-
// mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
|
|
634
|
-
// comillas dobles $ y backtick siguen expandiendo (se rechazan); en
|
|
635
|
-
// comillas simples todo es literal. Y si el escape esta pero se ignora,
|
|
636
|
-
// SE DICE: un escape ignorado en silencio era el bug.
|
|
637
|
-
const bad = escSimple();
|
|
638
|
-
if (bad === null) return true;
|
|
639
|
-
if (!escNoted.has(name)) {
|
|
640
|
-
escNoted.add(name);
|
|
641
|
-
console.error("karajan sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)" + doc("escapes"));
|
|
642
|
-
}
|
|
643
|
-
return false;
|
|
644
|
-
};
|
|
645
|
-
const escNoted = new Set();
|
|
646
|
-
const escSimple = () => {
|
|
647
|
-
let q = null;
|
|
648
|
-
for (let i = 0; i < CMD_TEXT.length; i += 1) {
|
|
649
|
-
const c = CMD_TEXT[i];
|
|
650
|
-
if (q === "'") { if (c === "'") q = null; continue; }
|
|
651
|
-
if (q === '"') {
|
|
652
|
-
if (c === '"') q = null;
|
|
653
|
-
else if (c === "$" || c === ESC_BT) return c;
|
|
654
|
-
else if (c === "\\\\") i += 1;
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
if (c === "\\\\") { i += 1; continue; }
|
|
658
|
-
if (c === "'" || c === '"') { q = c; continue; }
|
|
659
|
-
if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
|
|
660
|
-
}
|
|
661
|
-
return q === null ? null : "comilla sin cerrar";
|
|
662
|
-
};
|
|
663
592
|
// IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
|
|
664
593
|
// run under an account other than the one THIS CLONE declared (the incident:
|
|
665
594
|
// a gh comment without switch went out as the client account). Deny BEFORE
|
|
@@ -755,11 +684,8 @@ process.stdin.on("end", () => {
|
|
|
755
684
|
};
|
|
756
685
|
scan(CMD_TEXT, 0);
|
|
757
686
|
if (problems.length > 0) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)" + doc("identity"));
|
|
761
|
-
process.exit(2);
|
|
762
|
-
}
|
|
687
|
+
console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(sin escape, ADR 0015: pon la cuenta en el comando)" + doc("identity"));
|
|
688
|
+
process.exit(2);
|
|
763
689
|
}
|
|
764
690
|
}
|
|
765
691
|
// MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
|
|
@@ -779,8 +705,7 @@ process.stdin.on("end", () => {
|
|
|
779
705
|
return d ? foreignLane(d) : null;
|
|
780
706
|
};
|
|
781
707
|
const laneDeny = (what, lane) => {
|
|
782
|
-
|
|
783
|
-
console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado." + doc("cross-lane"));
|
|
708
|
+
console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
|
|
784
709
|
return true;
|
|
785
710
|
};
|
|
786
711
|
if (EDIT_TOOLS.includes(tool)) {
|
|
@@ -817,11 +742,8 @@ process.stdin.on("end", () => {
|
|
|
817
742
|
// directorio se deniega conservadoramente — el remedio es no
|
|
818
743
|
// necesitarlo (git -C, npm --prefix, rutas absolutas).
|
|
819
744
|
if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
823
|
-
process.exit(2);
|
|
824
|
-
}
|
|
745
|
+
console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd." + doc("cross-lane"));
|
|
746
|
+
process.exit(2);
|
|
825
747
|
}
|
|
826
748
|
// Absolute AND dot-relative tokens (../lane escapaba al primer scan —
|
|
827
749
|
// reviewer catch); relative se resuelve contra el cwd de la sesion.
|
|
@@ -836,11 +758,8 @@ process.stdin.on("end", () => {
|
|
|
836
758
|
// de tokens de abajo; el residuo (expansion anidada en segmentos
|
|
837
759
|
// runner) queda documentado como en la regla de ficheros PROTECTED.
|
|
838
760
|
if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
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"));
|
|
842
|
-
process.exit(2);
|
|
843
|
-
}
|
|
761
|
+
console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL." + doc("cross-lane"));
|
|
762
|
+
process.exit(2);
|
|
844
763
|
}
|
|
845
764
|
// Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
|
|
846
765
|
// en cualquier posicion, y una ruta entrecomillada con espacios es
|
|
@@ -904,20 +823,14 @@ process.stdin.on("end", () => {
|
|
|
904
823
|
// KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
|
|
905
824
|
// of a text option (--title, -m...) is not a path.
|
|
906
825
|
if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
910
|
-
process.exit(2);
|
|
911
|
-
}
|
|
826
|
+
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
|
|
827
|
+
process.exit(2);
|
|
912
828
|
}
|
|
913
829
|
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]*$/;
|
|
914
830
|
const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
|
|
915
831
|
if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
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"));
|
|
919
|
-
process.exit(2);
|
|
920
|
-
}
|
|
832
|
+
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." + doc("cross-lane"));
|
|
833
|
+
process.exit(2);
|
|
921
834
|
}
|
|
922
835
|
// ...incluidas relativas A PELO (los carriles kj viven en
|
|
923
836
|
// .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
|
|
@@ -955,8 +868,8 @@ process.stdin.on("end", () => {
|
|
|
955
868
|
// Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
|
|
956
869
|
// gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
|
|
957
870
|
// deny duro (el remedio es restaurar kj); cualquier otro fallo de
|
|
958
|
-
// evaluacion = deny
|
|
959
|
-
//
|
|
871
|
+
// evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
|
|
872
|
+
// usuario fuera de la sesion.
|
|
960
873
|
if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
|
|
961
874
|
// PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
|
|
962
875
|
// kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
|
|
@@ -970,11 +883,9 @@ process.stdin.on("end", () => {
|
|
|
970
883
|
let v = null;
|
|
971
884
|
try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
|
|
972
885
|
const secure = !!(v && v.class === "security");
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
process.exit(2);
|
|
977
|
-
}
|
|
886
|
+
// ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
|
|
887
|
+
console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (sin escape: si la regla falla, tu usuario corrige .karajan/policy.yml por PR)") + doc("policy"));
|
|
888
|
+
process.exit(2);
|
|
978
889
|
} else if (pres.status !== 0) {
|
|
979
890
|
// KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
|
|
980
891
|
// kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
|
|
@@ -999,9 +910,8 @@ process.stdin.on("end", () => {
|
|
|
999
910
|
console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
|
|
1000
911
|
process.exit(2);
|
|
1001
912
|
}
|
|
1002
|
-
} else
|
|
1003
|
-
|
|
1004
|
-
console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
|
|
913
|
+
} else {
|
|
914
|
+
console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check; .karajan/policy.yml lo corrige tu usuario fuera de la sesion." + doc("policy"));
|
|
1005
915
|
process.exit(2);
|
|
1006
916
|
}
|
|
1007
917
|
}
|
|
@@ -1022,11 +932,8 @@ process.stdin.on("end", () => {
|
|
|
1022
932
|
let blockOn = false;
|
|
1023
933
|
try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
|
|
1024
934
|
if (hard.length && blockOn) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)" + doc("steward"));
|
|
1028
|
-
process.exit(2);
|
|
1029
|
-
}
|
|
935
|
+
console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(sin escape, ADR 0015: si el trabajo sigue, tu usuario cambia method_gates.steward a inform)" + doc("steward"));
|
|
936
|
+
process.exit(2);
|
|
1030
937
|
}
|
|
1031
938
|
const st = load();
|
|
1032
939
|
const s = session(st, sid);
|
|
@@ -1043,11 +950,11 @@ process.stdin.on("end", () => {
|
|
|
1043
950
|
if (rel && CODE.test(rel) && !TESTS.test(rel)) {
|
|
1044
951
|
const branch = branchOf();
|
|
1045
952
|
const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
|
|
953
|
+
// ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
|
|
1046
954
|
if (why) {
|
|
1047
|
-
if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
|
|
1048
955
|
console.error(why === "base"
|
|
1049
|
-
? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion.
|
|
1050
|
-
: "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion.
|
|
956
|
+
? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion." + doc("card-first")
|
|
957
|
+
: "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion." + doc("card-first"));
|
|
1051
958
|
process.exit(2);
|
|
1052
959
|
}
|
|
1053
960
|
// KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
|
|
@@ -1070,15 +977,14 @@ process.stdin.on("end", () => {
|
|
|
1070
977
|
// KJC-BUG-0190: with nothing indexed, no query can cover anything —
|
|
1071
978
|
// the gate asks for kj rag index instead of denying the impossible.
|
|
1072
979
|
const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
|
|
980
|
+
// ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
|
|
981
|
+
// index, not indexable) are decided by the gate itself.
|
|
1073
982
|
if (!covered) {
|
|
1074
|
-
|
|
1075
|
-
// Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
|
|
1076
|
-
if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
|
|
1077
|
-
} else {
|
|
983
|
+
{
|
|
1078
984
|
// KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
|
|
1079
985
|
// sin adapter NO puede estar en el indice, asi que exigir una
|
|
1080
986
|
// respuesta del RAG sobre el es pedir lo imposible, y empujaba a
|
|
1081
|
-
// dejar
|
|
987
|
+
// dejar el escape puesto para siempre. La exencion es SOLO
|
|
1082
988
|
// esa: un indice vacio o por detras si tiene arreglo (kj rag index)
|
|
1083
989
|
// y sigue bloqueando, porque si no el gate desapareceria en
|
|
1084
990
|
// cualquier proyecto sin indexar. Nada legible = se bloquea.
|
|
@@ -1098,7 +1004,7 @@ process.stdin.on("end", () => {
|
|
|
1098
1004
|
console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
|
|
1099
1005
|
process.exit(2);
|
|
1100
1006
|
} else {
|
|
1101
|
-
console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>.
|
|
1007
|
+
console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>." + doc("rag-first"));
|
|
1102
1008
|
process.exit(2);
|
|
1103
1009
|
}
|
|
1104
1010
|
}
|
|
@@ -1133,11 +1039,10 @@ process.stdin.on("end", () => {
|
|
|
1133
1039
|
// depender de como parta las palabras el separador (catch de la review).
|
|
1134
1040
|
const movesHooks = cmd.toLowerCase().includes("core.hookspath");
|
|
1135
1041
|
if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
}
|
|
1042
|
+
// ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
|
|
1043
|
+
// if it ever must happen, is the user's act in their own terminal.
|
|
1044
|
+
console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
|
|
1045
|
+
process.exit(2);
|
|
1141
1046
|
}
|
|
1142
1047
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1143
1048
|
const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
|
|
@@ -1145,20 +1050,14 @@ process.stdin.on("end", () => {
|
|
|
1145
1050
|
const pend = pendingMoves(load().sessions?.[sid]);
|
|
1146
1051
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
1147
1052
|
// KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
|
|
1148
|
-
// por su primera mitad
|
|
1149
|
-
|
|
1150
|
-
const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
1151
|
-
let blocking = pend, carried = [];
|
|
1152
|
-
if (!bg.error && (bg.status === 0 || bg.status === 2)) {
|
|
1153
|
-
try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
|
|
1154
|
-
}
|
|
1053
|
+
// por su primera mitad (boardGate, en la lib).
|
|
1054
|
+
const { blocking, carried } = boardGate(sid, pend);
|
|
1155
1055
|
for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
|
|
1056
|
+
// ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
|
|
1057
|
+
// are already decided above (KJC-BUG-0230, 0198).
|
|
1156
1058
|
if (blocking.length > 0) {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
|
|
1160
|
-
process.exit(2);
|
|
1161
|
-
}
|
|
1059
|
+
console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + doc("board-sync"));
|
|
1060
|
+
process.exit(2);
|
|
1162
1061
|
}
|
|
1163
1062
|
}
|
|
1164
1063
|
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
@@ -1167,9 +1066,9 @@ process.stdin.on("end", () => {
|
|
|
1167
1066
|
// there misleads every future reader. When the command writes one from a
|
|
1168
1067
|
// file, kj crosses ITS data against this turn's outputs; the hook carries
|
|
1169
1068
|
// no policy (kj reads method_gates.claims; off = this check does not exist).
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1069
|
+
// KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
|
|
1070
|
+
const body = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
|
|
1071
|
+
if (body && transcript) {
|
|
1173
1072
|
const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
|
|
1174
1073
|
if (!g.error && g.status === 2) {
|
|
1175
1074
|
console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
|
|
@@ -1177,7 +1076,8 @@ process.stdin.on("end", () => {
|
|
|
1177
1076
|
}
|
|
1178
1077
|
}
|
|
1179
1078
|
if (isPublish(cmd)) {
|
|
1180
|
-
|
|
1079
|
+
// ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
|
|
1080
|
+
// check from blocking its own fix.
|
|
1181
1081
|
// KJC-BUG-0204: el check se evalua PARA este comando. Un item que
|
|
1182
1082
|
// declara remedied_by no bloquea el comando que lo repara (desplegar
|
|
1183
1083
|
// la landing era justo lo que el check pedia); publicar no se exime.
|
|
@@ -1194,19 +1094,15 @@ process.stdin.on("end", () => {
|
|
|
1194
1094
|
if (res.status !== 0) {
|
|
1195
1095
|
let items = "";
|
|
1196
1096
|
try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
|
|
1197
|
-
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(
|
|
1097
|
+
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
|
|
1198
1098
|
process.exit(2);
|
|
1199
1099
|
}
|
|
1200
1100
|
} else if (PUSH.test(cmd)) {
|
|
1201
|
-
// KJC-BUG-0147:
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1101
|
+
// KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
|
|
1102
|
+
// ADR 0015 (KJC-TSK-0925): with no escape.
|
|
1103
|
+
// KJC-BUG-0262: a carried card does not block the push the commit let through.
|
|
1204
1104
|
const sess = load().sessions?.[sid];
|
|
1205
|
-
|
|
1206
|
-
if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
|
|
1207
|
-
recordEscape(sid, "KJ_ALLOW_BOARD", tool);
|
|
1208
|
-
v = violations({ ...sess, pending_moves: [] }, branchOf());
|
|
1209
|
-
}
|
|
1105
|
+
const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
|
|
1210
1106
|
if (v.length) {
|
|
1211
1107
|
console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
|
|
1212
1108
|
process.exit(2);
|
|
@@ -24,7 +24,7 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
|
24
24
|
* @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
|
|
25
25
|
* supervisor cuyo estado actual está respaldado por la provenance sellada.
|
|
26
26
|
*/
|
|
27
|
-
export function verifiedSupervisorFiles({ projectDir }) {
|
|
27
|
+
export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
28
28
|
let prov;
|
|
29
29
|
try {
|
|
30
30
|
prov = JSON.parse(readFileSync(resolve(projectDir, PROVENANCE_FILE), "utf8"));
|
|
@@ -37,7 +37,11 @@ export function verifiedSupervisorFiles({ projectDir }) {
|
|
|
37
37
|
}
|
|
38
38
|
const ok = new Set();
|
|
39
39
|
const unjudgeable = new Set();
|
|
40
|
-
|
|
40
|
+
// KJC-BUG-0259 (user's decision, option A): history is judged with the kj of
|
|
41
|
+
// when it was committed. The harness guards are machine-local and never in the
|
|
42
|
+
// repo; the review and each PR's CI verify them when the seal is made. A check
|
|
43
|
+
// over the repo's history (release check) judges only what is versioned.
|
|
44
|
+
const entries = (Array.isArray(prov?.files) ? prov.files : []).filter((e) => !trackedOnly || !String(e?.file).startsWith(HARNESS_PREFIX));
|
|
41
45
|
// KJC-BUG-0211: desde KJC-BUG-0197 el sello cubre tambien los guardias del
|
|
42
46
|
// supervisor, y aqui solo se sabian verificar los hooks. Como `complete`
|
|
43
47
|
// exige que TODAS las entradas verifiquen, el sello no se podia ni commitear.
|
|
@@ -93,10 +97,10 @@ export function verifiedSupervisorFiles({ projectDir }) {
|
|
|
93
97
|
* `note` dice lo que este sitio NO ha podido juzgar, para que un levantamiento
|
|
94
98
|
* mas laxo de lo que parece no pase por silencio (KJC-BUG-0212).
|
|
95
99
|
*/
|
|
96
|
-
export function liftSealedSupervisorViolations({ projectDir, violations }) {
|
|
100
|
+
export function liftSealedSupervisorViolations({ projectDir, violations, trackedOnly = false }) {
|
|
97
101
|
const RULE = "defaults.supervisor.write";
|
|
98
102
|
if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
|
|
99
|
-
const sealed = verifiedSupervisorFiles({ projectDir });
|
|
103
|
+
const sealed = verifiedSupervisorFiles({ projectDir, trackedOnly });
|
|
100
104
|
// La violación sobre la PROPIA provenance solo se alza si la provenance
|
|
101
105
|
// entera verificó — a medias no describe la verdad y sigue denegada.
|
|
102
106
|
const liftable = (v) =>
|
package/src/review/card-first.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* hu-board → the branch must reference a LIVE card (block by default);
|
|
5
5
|
* planning-game/external → card-shaped reference required (warn default,
|
|
6
6
|
* block via method_gates.card_first). Exemptions explicit and visible:
|
|
7
|
-
* base branch
|
|
7
|
+
* base branch and configured prefixes (releases). No escape (ADR 0015): a
|
|
8
|
+
* gate an agent can switch off is a suggestion.
|
|
8
9
|
*/
|
|
9
10
|
import { listPlans, loadPlan } from "../plan/plan-store.js";
|
|
10
11
|
import { escapeRegExp } from "../utils/escape-regexp.js";
|
|
@@ -48,10 +49,7 @@ async function findLiveCardInBranch(projectDir, branch) {
|
|
|
48
49
|
return closedHit;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch,
|
|
52
|
-
if (env.KJ_ALLOW_NO_CARD === "1") {
|
|
53
|
-
return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
|
|
54
|
-
}
|
|
52
|
+
export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, deps = {} }) {
|
|
55
53
|
const exempt = config.method_gates?.card_first_exempt_branches || DEFAULT_EXEMPT_PREFIXES;
|
|
56
54
|
if (exempt.some((p) => branch.startsWith(p))) {
|
|
57
55
|
return { ok: true, mode: "exempt", reason: `branch prefix exempt (${branch.split("/")[0]}/…)` };
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Adaptador del gate de policy en el flujo de review de karajan-code
|
|
3
3
|
* (GOV-A, KJC-TSK-0745). La decisión warn/deny/inexcepcionable/excepción
|
|
4
|
-
* vive en el kernel (@karajan-family/governance); aquí solo el dominio:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* registrado dice "este diff exacto".
|
|
4
|
+
* vive en el kernel (@karajan-family/governance); aquí solo el dominio: el
|
|
5
|
+
* artefacto es el diff staged. Sin excepción por diff (ADR 0015,
|
|
6
|
+
* KJC-TSK-0932): una regla que falla se corrige por PR.
|
|
8
7
|
*/
|
|
9
8
|
import { evaluateGate } from "@karajan-family/governance";
|
|
10
9
|
|
|
@@ -13,13 +12,12 @@ import { evaluateGate } from "@karajan-family/governance";
|
|
|
13
12
|
*/
|
|
14
13
|
export function evaluatePolicyGate({
|
|
15
14
|
policy, errors = [], role = "coder", files = [], netLinesAdded = null,
|
|
16
|
-
diffHashValue = null,
|
|
15
|
+
diffHashValue = null, recordException = () => {},
|
|
17
16
|
standingExceptions = [], now = new Date(),
|
|
18
17
|
}) {
|
|
19
18
|
return evaluateGate({
|
|
20
19
|
policy, errors, role, files, netLinesAdded,
|
|
21
20
|
artifactHash: diffHashValue,
|
|
22
|
-
exemption: { requested: env.KJ_ALLOW_POLICY === "1", justification: env.KJ_POLICY_REASON, hint: 'KJ_POLICY_REASON="por qué"' },
|
|
23
21
|
recordException: (entry) => recordException({ ...entry, scope: "este diff exacto" }),
|
|
24
22
|
standingExceptions, now,
|
|
25
23
|
});
|
|
@@ -110,9 +110,9 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
110
110
|
}
|
|
111
111
|
for (const f of own) indexed.add(`${prefix}${f}`);
|
|
112
112
|
keys.push(scan.projectKey);
|
|
113
|
-
// The scan above ALWAYS runs before issues are read (single-flight)
|
|
114
|
-
//
|
|
115
|
-
//
|
|
113
|
+
// The scan above ALWAYS runs before issues are read (single-flight), and
|
|
114
|
+
// it returns once the server has processed it (KJC-BUG-0263: before, the
|
|
115
|
+
// upload returned first and the issues read were the previous analysis).
|
|
116
116
|
const res = await getOpenIssues(config, scan.projectKey);
|
|
117
117
|
totalProject += res.total ?? (res.issues || []).length;
|
|
118
118
|
// Issue components are package-relative: made repo-relative here.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `method_gates.tests_with_code`. Test-FIRST remains the reviewer's
|
|
6
6
|
* judgment; test-PRESENT belongs to the gate. Patterns come from the
|
|
7
7
|
* project's own `development.*` config (same ones headless enforces).
|
|
8
|
-
*
|
|
8
|
+
* No escape (ADR 0015): deletions and comment-only cleanups already pass.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
|
|
@@ -28,10 +28,7 @@ export function sourceFilesOf(config = {}, files = []) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null
|
|
32
|
-
if (env.KJ_ALLOW_NO_TESTS === "1") {
|
|
33
|
-
return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
|
|
34
|
-
}
|
|
31
|
+
export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null }) {
|
|
35
32
|
const { sources, hasTests } = sourceFilesOf(config, stagedFiles);
|
|
36
33
|
|
|
37
34
|
if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
|
|
@@ -51,7 +48,7 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = nu
|
|
|
51
48
|
}
|
|
52
49
|
|
|
53
50
|
const policy = config.method_gates?.tests_with_code || "warn";
|
|
54
|
-
const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first
|
|
51
|
+
const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first: add one`;
|
|
55
52
|
return policy === "block"
|
|
56
53
|
? { ok: false, mode: "block", sources, reason }
|
|
57
54
|
: { ok: true, mode: "warn", sources, reason };
|
package/src/sonar/api.js
CHANGED
|
@@ -82,6 +82,27 @@ async function sonarFetch(config, urlPath) {
|
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
const CE_FINAL = new Set(["SUCCESS", "FAILED", "CANCELED"]);
|
|
86
|
+
const sleepMs = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* KJC-BUG-0263 — the scanner uploads the analysis and returns; the server
|
|
90
|
+
* processes it in the background, and issues read before that task ends are
|
|
91
|
+
* the PREVIOUS analysis. Polls /api/ce/task until it reaches a final status.
|
|
92
|
+
* @returns {Promise<{ok: boolean, status: string, timedOut?: boolean}>}
|
|
93
|
+
*/
|
|
94
|
+
export async function waitForAnalysis(config, taskId, { timeoutMs = 120_000, intervalMs = 1000, sleep = sleepMs } = {}) {
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
let status = "UNKNOWN";
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
const body = await sonarFetch(config, `/api/ce/task?id=${encodeURIComponent(taskId)}`);
|
|
99
|
+
try { status = JSON.parse(body).task?.status || "UNKNOWN"; } catch { status = "UNKNOWN"; }
|
|
100
|
+
if (CE_FINAL.has(status)) return { ok: status === "SUCCESS", status };
|
|
101
|
+
await sleep(intervalMs);
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, status, timedOut: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
/**
|
|
86
107
|
* KJC-BUG-0226 (issue #1838) — de donde sale la clave que se CONSULTA. El
|
|
87
108
|
* scanner sube el analisis con la que el repo declara en su
|
package/src/sonar/scanner.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { runCommand } from "../utils/process.js";
|
|
6
6
|
import { sonarUp } from "./manager.js";
|
|
7
|
+
import { waitForAnalysis } from "./api.js";
|
|
7
8
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
8
9
|
import {
|
|
9
10
|
resolveSonarHost,
|
|
@@ -380,14 +381,32 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
|
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env, cwd: scanCwd });
|
|
384
|
+
// KJC-BUG-0263: the scan is done when the server has processed it; whoever reads
|
|
385
|
+
// the issues next would otherwise get the previous analysis. Said out loud when
|
|
386
|
+
// the log names no task to wait for.
|
|
387
|
+
let ok = result.exitCode === 0;
|
|
388
|
+
let analysisNote = null;
|
|
389
|
+
if (ok) {
|
|
390
|
+
const taskId = /api\/ce\/task\?id=([\w-]+)/.exec(String(result.stdout || ""))?.[1];
|
|
391
|
+
if (taskId) {
|
|
392
|
+
const done = await waitForAnalysis({ ...config, sonarqube: { ...sonarConfig, host: apiHost } }, taskId);
|
|
393
|
+
if (!done.ok) {
|
|
394
|
+
ok = false;
|
|
395
|
+
analysisNote = `sonar: the server did not finish analysis ${taskId} (${done.status}${done.timedOut ? ", timed out" : ""}) — its issues would be the previous analysis`;
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
analysisNote = "sonar: the scanner log names no analysis task, so kj could not wait for the server — issues may belong to the previous analysis";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
383
401
|
return {
|
|
384
|
-
ok
|
|
402
|
+
ok,
|
|
385
403
|
projectKey: effectiveProjectKey,
|
|
386
404
|
cwd: scanCwd,
|
|
387
405
|
scanner: pick.type,
|
|
388
|
-
note,
|
|
406
|
+
note: [note, analysisNote].filter(Boolean).join("; ") || null,
|
|
389
407
|
stdout: result.stdout,
|
|
390
|
-
|
|
408
|
+
// A scan that failed on the server's side says so, not with the scanner's own log.
|
|
409
|
+
stderr: !ok && result.exitCode === 0 ? analysisNote : result.stderr,
|
|
391
410
|
exitCode: result.exitCode
|
|
392
411
|
};
|
|
393
412
|
}
|