karajan-code 4.15.0 → 4.17.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/install.js +22 -1
- package/scripts/verify-pack.mjs +28 -6
- package/src/agents/base-agent.js +1 -1
- package/src/checks/release-check.js +10 -17
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +25 -0
- package/src/commands/env.js +1 -1
- package/src/commands/harden.js +4 -1
- package/src/commands/policy.js +70 -0
- package/src/commands/rag.js +4 -4
- package/src/commands/review-gate.js +3 -0
- package/src/config/defaults.js +3 -1
- package/src/environment/board-select.js +2 -1
- package/src/harden/hook-templates.js +2 -2
- package/src/harden/sentinel-hooks.js +121 -2
- package/src/orchestrator/direct-actions.js +12 -1
- package/src/policy/engine.js +276 -0
- package/src/policy/glob.js +44 -0
- package/src/review/card-first.js +19 -5
- package/src/review/card-verify.js +83 -0
- package/src/sonar/scanner.js +11 -7
- package/src/tournament/judge.js +2 -3
- package/src/tournament/scoreboard.js +1 -1
- package/src/utils/update-check.js +2 -1
package/package.json
CHANGED
|
@@ -8,7 +8,14 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
11
|
-
|
|
11
|
+
// KJC-BUG-0095: the bare command fails SILENTLY when the bin drops off PATH
|
|
12
|
+
// (nvm switch, global reinstall) — the anti-delete net goes down without
|
|
13
|
+
// notice. The installed line fails CLOSED: a missing bin blocks the tool
|
|
14
|
+
// call (exit 2) naming the net and the remedy, never a quiet pass-through.
|
|
15
|
+
export const LEGACY_HOOK_COMMAND = "kj-trash hook";
|
|
16
|
+
export const HOOK_COMMAND =
|
|
17
|
+
"if command -v kj-trash >/dev/null 2>&1; then exec kj-trash hook; fi; " +
|
|
18
|
+
"echo 'kj-trash: bin not on PATH — the anti-delete net is DOWN. Fix: npm i -g karajan-code (or remove this hook from ~/.claude/settings.json).' >&2; exit 2";
|
|
12
19
|
const MATCHER = "Bash";
|
|
13
20
|
|
|
14
21
|
async function readSettings(path) {
|
|
@@ -34,6 +41,20 @@ export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHE
|
|
|
34
41
|
entry = { matcher, hooks: [{ type: "command", command }] };
|
|
35
42
|
preToolUse.push(entry);
|
|
36
43
|
mutated = true;
|
|
44
|
+
} else if (hasHookCommand(entry, LEGACY_HOOK_COMMAND) && command !== LEGACY_HOOK_COMMAND) {
|
|
45
|
+
// KJC-BUG-0095 migration: upgrade the silent legacy line in place —
|
|
46
|
+
// deduped (reviewer catch): an entry already carrying the new line
|
|
47
|
+
// must end up with a single copy, not legacy-turned-duplicate.
|
|
48
|
+
let seen = false;
|
|
49
|
+
entry.hooks = entry.hooks
|
|
50
|
+
.map((h) => (h.type === "command" && h.command === LEGACY_HOOK_COMMAND ? { ...h, command } : h))
|
|
51
|
+
.filter((h) => {
|
|
52
|
+
if (!(h.type === "command" && h.command === command)) return true;
|
|
53
|
+
if (seen) return false;
|
|
54
|
+
seen = true;
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
mutated = true;
|
|
37
58
|
} else if (!hasHookCommand(entry, command)) {
|
|
38
59
|
entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
|
|
39
60
|
mutated = true;
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -208,7 +208,17 @@ try {
|
|
|
208
208
|
// better-sqlite3's native build by default, so DB-backed commands need
|
|
209
209
|
// `pnpm approve-builds`; that's surfaced as a doctor warning, KJC-BUG-0092 —
|
|
210
210
|
// not a gate failure, since it's inherent to pnpm.) Auto-skips without pnpm.
|
|
211
|
-
|
|
211
|
+
// KJC-BUG-0135: `npm run` leaks npm_config_* into the child env, and pnpm
|
|
212
|
+
// maps those variables onto its own config — which can re-anchor it to THIS
|
|
213
|
+
// repo (7-aug incident: the repo's node_modules converted to pnpm layout,
|
|
214
|
+
// native bindings wiped, and simple-git-hooks' hook overwrote
|
|
215
|
+
// .karajan/hooks). The smoke runs with a scrubbed env and a pinned --dir,
|
|
216
|
+
// and the tripwire below turns any host contamination into a loud gate
|
|
217
|
+
// failure instead of a silent green.
|
|
218
|
+
const pnpmEnv = Object.fromEntries(Object.entries(childEnv).filter(([k]) => !/^npm_config_/i.test(k)));
|
|
219
|
+
const HOST_MARKERS = ["pnpm-lock.yaml", "pnpm-workspace.yaml", path.join("node_modules", ".pnpm")];
|
|
220
|
+
const hostMarkersBefore = new Set(HOST_MARKERS.filter((m) => fs.existsSync(path.join(repoRoot, m))));
|
|
221
|
+
const hasPnpm = spawnSync("pnpm", ["--version"], { encoding: "utf8", env: pnpmEnv }).status === 0;
|
|
212
222
|
if (!hasPnpm) {
|
|
213
223
|
console.log("verify-pack: pnpm not installed — skipping pnpm smoke (npm path covered above) ⚠");
|
|
214
224
|
} else {
|
|
@@ -224,11 +234,11 @@ try {
|
|
|
224
234
|
// versions (supply-chain protection), so right after publishing
|
|
225
235
|
// karajan-core it silently resolves an OLD one and this smoke fails on
|
|
226
236
|
// missing subpaths. The gate verifies packaging, not release-age policy.
|
|
227
|
-
spawnSync(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
cwd: pnpmTmp,
|
|
231
|
-
|
|
237
|
+
spawnSync(
|
|
238
|
+
"pnpm",
|
|
239
|
+
["add", tgzPath, "--dir", pnpmTmp, "--ignore-workspace", "--store-dir", path.join(pnpmTmp, ".store"), "--config.minimum-release-age=0"],
|
|
240
|
+
{ encoding: "utf8", env: pnpmEnv, cwd: pnpmTmp },
|
|
241
|
+
);
|
|
232
242
|
const pnpmBin = path.join(pnpmTmp, "node_modules", ".bin", "kj");
|
|
233
243
|
if (!fs.existsSync(pnpmBin)) fail(`kj binary missing after pnpm install: ${pnpmBin}`);
|
|
234
244
|
let pnpmVersion;
|
|
@@ -242,6 +252,18 @@ try {
|
|
|
242
252
|
}
|
|
243
253
|
console.log(`verify-pack: pnpm install + kj --version → ${pnpmVersion} ✓ (DB build needs \`pnpm approve-builds\`)`);
|
|
244
254
|
}
|
|
255
|
+
// Tripwire (KJC-BUG-0135): the smoke must NEVER touch the host repo. If a
|
|
256
|
+
// pnpm artifact appeared at the repo root during this run, the gate fails
|
|
257
|
+
// naming it — never a green over a contaminated tree.
|
|
258
|
+
const contaminated = HOST_MARKERS.filter(
|
|
259
|
+
(m) => !hostMarkersBefore.has(m) && fs.existsSync(path.join(repoRoot, m)),
|
|
260
|
+
);
|
|
261
|
+
if (contaminated.length > 0) {
|
|
262
|
+
fail(
|
|
263
|
+
`pnpm smoke contaminated the host repo: ${contaminated.join(", ")} appeared at the repo root`,
|
|
264
|
+
"remove the listed files and run `npm ci` to restore the npm layout; then verify .karajan/hooks with `git status`",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
245
267
|
|
|
246
268
|
console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
|
|
247
269
|
} finally {
|
package/src/agents/base-agent.js
CHANGED
|
@@ -60,7 +60,7 @@ export class BaseAgent {
|
|
|
60
60
|
// a PATH is a partial overlay and merges over the inherited env first,
|
|
61
61
|
// so an overlay caller can never cost the child PATH/HOME.
|
|
62
62
|
const complete = options.env && ("PATH" in options.env || "Path" in options.env);
|
|
63
|
-
const base = complete ? options.env : { ...process.env, ...
|
|
63
|
+
const base = complete ? options.env : { ...process.env, ...options.env };
|
|
64
64
|
options = {
|
|
65
65
|
...options,
|
|
66
66
|
env: buildAgentEnv(base, {
|
|
@@ -28,9 +28,7 @@ async function genericChecks(projectDir) {
|
|
|
28
28
|
checks.push({ name: "manifest", ok: Boolean(version), detail: version ? `version ${version}` : "package.json has no version" });
|
|
29
29
|
|
|
30
30
|
const clPath = join(projectDir, "CHANGELOG.md");
|
|
31
|
-
if (
|
|
32
|
-
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
33
|
-
} else {
|
|
31
|
+
if (existsSync(clPath)) {
|
|
34
32
|
const cl = readFileSync(clPath, "utf8");
|
|
35
33
|
const topVersioned = (cl.match(/^## \[(\d+\.\d+\.\d+)\]/m) || [])[1] || null;
|
|
36
34
|
// Content still sitting under [Unreleased] means unpromoted entries —
|
|
@@ -39,25 +37,20 @@ async function genericChecks(projectDir) {
|
|
|
39
37
|
const unreleasedBody = afterUnreleased.split(/^## \[/m)[0] ?? "";
|
|
40
38
|
const pending = /\S/.test(unreleasedBody);
|
|
41
39
|
const ok = topVersioned === version && !pending;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
? `[Unreleased] still carries content — promote it into [${version}] before releasing`
|
|
49
|
-
: `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`,
|
|
50
|
-
});
|
|
40
|
+
let detail = `top section is [${version}], Unreleased empty`;
|
|
41
|
+
if (pending) detail = `[Unreleased] still carries content — promote it into [${version}] before releasing`;
|
|
42
|
+
else if (!ok) detail = `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`;
|
|
43
|
+
checks.push({ name: "changelog-current", ok, detail });
|
|
44
|
+
} else {
|
|
45
|
+
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
const tagsOut = await runCommand("git", ["-C", projectDir, "tag", "--list", "v[0-9]*"]);
|
|
54
49
|
const tags = (tagsOut.stdout || "").split("\n").map((t) => t.trim().replace(/^v/, "")).filter((t) => /^\d+\.\d+\.\d+$/.test(t));
|
|
55
50
|
const ahead = version ? tags.filter((t) => semverCmp(t, version) > 0) : [];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
detail: ahead.length ? `tag(s) ahead of the manifest exist: v${ahead.join(", v")}` : (tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`),
|
|
60
|
-
});
|
|
51
|
+
let tagDetail = tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`;
|
|
52
|
+
if (ahead.length > 0) tagDetail = `tag(s) ahead of the manifest exist: v${ahead.join(", v")}`;
|
|
53
|
+
checks.push({ name: "tags", ok: ahead.length === 0, detail: tagDetail });
|
|
61
54
|
return { checks, version, pkg };
|
|
62
55
|
}
|
|
63
56
|
|
|
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
|
|
34
34
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -284,6 +284,31 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
288
|
+
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado) — PL-A: modo warn");
|
|
289
|
+
policyCmd.command("eval")
|
|
290
|
+
.description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
|
|
291
|
+
.option("--role <role>", "Rol del agente", "coder")
|
|
292
|
+
.option("--tool <tool>", "Tool (Write, Edit, Bash…)")
|
|
293
|
+
.option("--input <json>", "tool_input en JSON")
|
|
294
|
+
.option("--strict", "Exit 2 si deny")
|
|
295
|
+
.action(async (flags) => {
|
|
296
|
+
await withConfig(pkgVersion, "policy-eval", flags, async ({ config }) => {
|
|
297
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
298
|
+
process.exitCode = await policyCommand({ action: "eval", config, flags });
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
policyCmd.command("check")
|
|
302
|
+
.description("Comprueba el diff STAGED contra la policy (write-deny + invariantes) — siempre warn en PL-A; exit 1 solo si policy.yml es inválido")
|
|
303
|
+
.option("--role <role>", "Rol del agente", "coder")
|
|
304
|
+
.option("--json", "Machine-readable")
|
|
305
|
+
.action(async (flags) => {
|
|
306
|
+
await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
|
|
307
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
308
|
+
process.exitCode = await policyCommand({ action: "check", config, flags });
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
287
312
|
// KJC-TSK-0713 — the Sentinel: the method state the harness hooks record.
|
|
288
313
|
const sentinel = program.command("sentinel").description("Deterministic method supervisor wired into the harness hooks (Claude Code)");
|
|
289
314
|
sentinel.command("status")
|
package/src/commands/env.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
62
62
|
// The effective backend feeds the render on EVERY non-error path —
|
|
63
63
|
// chosen, declared-in-file or default — so the playbook never falls
|
|
64
64
|
// back to hu-board while the selection said otherwise.
|
|
65
|
-
config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...
|
|
65
|
+
config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...config?.board, name: sel.boardName } : config?.board };
|
|
66
66
|
} catch (err) {
|
|
67
67
|
console.log(`⚠ board selection failed (${err.message}) — continuing with the configured default`);
|
|
68
68
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -184,7 +184,10 @@ export async function hardenCommand({
|
|
|
184
184
|
const verb = dryRun ? "would install" : "installed";
|
|
185
185
|
logger.info?.(`kj harden (${profile}) — ${verb} ${result.hooks.length} hook(s) → ${result.hooksPath}`);
|
|
186
186
|
for (const h of result.hooks) logger.info?.(` • ${h.hook}: ${h.action}`);
|
|
187
|
-
for (const c of out.configs)
|
|
187
|
+
for (const c of out.configs) {
|
|
188
|
+
const note = c.note ? ` (${c.note})` : "";
|
|
189
|
+
logger.info?.(` • ${c.file}: ${c.action}${note}`);
|
|
190
|
+
}
|
|
188
191
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
189
192
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
190
193
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj policy` — PL-A parte 3b (KJC-TSK-0733, épica KJC-PCS-0074). El motor
|
|
3
|
+
* en modo WARN: eval de una tool call y check del diff staged contra
|
|
4
|
+
* `.karajan/policy.yml`. En PL-A nada bloquea salvo un fichero de policy
|
|
5
|
+
* inválido (exit 1: declarar lo inaplicable es error del fichero, no aviso).
|
|
6
|
+
* `eval --strict` devuelve exit 2 en deny — el contrato para los adaptadores
|
|
7
|
+
* de hooks de PL-B. Los dientes de check llegan en PL-B/PL-C.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
async function stagedFacts(projectDir, gitFn) {
|
|
16
|
+
const run =
|
|
17
|
+
gitFn ||
|
|
18
|
+
(async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
|
19
|
+
const files = (await run(["diff", "--cached", "--name-only"]))
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map((s) => s.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
let net = 0;
|
|
24
|
+
for (const line of (await run(["diff", "--cached", "--numstat"])).split("\n")) {
|
|
25
|
+
const [a, r] = line.trim().split(/\s+/);
|
|
26
|
+
if (a && a !== "-") net += Number(a) || 0;
|
|
27
|
+
if (r && r !== "-") net -= Number(r) || 0;
|
|
28
|
+
}
|
|
29
|
+
return { files, netLinesAdded: net };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function policyCommand({ action, config = {}, flags = {}, logger = console, deps = {} }) {
|
|
33
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
34
|
+
const { policy, errors } = loadPolicy({ projectDir, deps });
|
|
35
|
+
if (errors.length > 0) {
|
|
36
|
+
for (const e of errors) logger.error?.(`✗ ${e}`);
|
|
37
|
+
logger.error?.("policy: el fichero declara lo que el motor no puede aplicar — corrígelo (una regla que miente es peor que ninguna)");
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (action === "eval") {
|
|
42
|
+
let input;
|
|
43
|
+
try {
|
|
44
|
+
input = flags.input ? JSON.parse(flags.input) : {};
|
|
45
|
+
} catch {
|
|
46
|
+
logger.error?.("policy eval: --input debe ser JSON válido");
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const verdict = evalToolCall(policy, { role: flags.role || "coder", tool: flags.tool, input, root: projectDir });
|
|
50
|
+
logger.info?.(JSON.stringify(verdict));
|
|
51
|
+
return verdict.decision === "deny" && flags.strict ? 2 : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// check — sobre el diff staged (único modo en PL-A), SIEMPRE warn.
|
|
55
|
+
const facts = await stagedFacts(projectDir, deps.gitFn);
|
|
56
|
+
const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
|
|
57
|
+
if (flags.json) {
|
|
58
|
+
logger.info?.(JSON.stringify({ mode: "warn", violations }));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (violations.length === 0) {
|
|
62
|
+
logger.info?.("policy check: limpio");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
for (const v of violations) {
|
|
66
|
+
logger.warn?.(`⚠ policy [${v.rule_id}] ${v.reason}${v.file ? ` (${v.file})` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
logger.warn?.(`policy check: ${violations.length} aviso(s) — modo warn (PL-A): no bloquea; PL-B le dará dientes`);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
package/src/commands/rag.js
CHANGED
|
@@ -98,7 +98,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
98
98
|
// KJC-TSK-0697 — `--library` targets the distilled-canon collection:
|
|
99
99
|
// its own project namespace plus the kind filter.
|
|
100
100
|
const library = Boolean(flags.library);
|
|
101
|
-
|
|
101
|
+
let project = flags.project === "all" ? null : (flags.project || detected || null);
|
|
102
|
+
if (library) project = LIBRARY_PROJECT;
|
|
102
103
|
// KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
|
|
103
104
|
// handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
|
|
104
105
|
// so agents (and the `/kj-rag-query` skill from Camino B) have a
|
|
@@ -115,9 +116,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
115
116
|
// Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
|
|
116
117
|
// AND-joined key=value pairs — an OR or parens never parses, so the
|
|
117
118
|
// composed clause cannot leak past the kind filter; it fails loudly.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
: (flags.where || null);
|
|
119
|
+
let where = flags.where || null;
|
|
120
|
+
if (library) where = flags.where ? `kind=library AND ${flags.where}` : "kind=library";
|
|
121
121
|
const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
|
|
122
122
|
const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
|
|
123
123
|
if (flags.json) {
|
|
@@ -26,6 +26,9 @@ async function enforceCardFirst({ config, projectDir }) {
|
|
|
26
26
|
const branch = branchRes.stdout?.trim() || "HEAD";
|
|
27
27
|
const card = await checkCardFirst({ config, projectDir, branch });
|
|
28
28
|
if (card.mode === "warn") console.log(`⚠ card-first: ${card.reason}`);
|
|
29
|
+
// KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
|
|
30
|
+
// branch-ref alone must never read as a tracker-verified pass.
|
|
31
|
+
if (card.note) console.log(`⚠ card-first: ${card.note}`);
|
|
29
32
|
if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
|
|
30
33
|
if (!card.ok) {
|
|
31
34
|
console.log(`✗ card-first gate: ${card.reason}`);
|
package/src/config/defaults.js
CHANGED
|
@@ -66,7 +66,9 @@ const DEFAULTS = {
|
|
|
66
66
|
// through the host agent's MCP/tools, never mirrored by kj. There is NO
|
|
67
67
|
// "none": Karajan does not run without a board.
|
|
68
68
|
state_backend: "hu-board",
|
|
69
|
-
|
|
69
|
+
// verify_cmd (KJC-TSK-0732): user adapter that checks a card ref against
|
|
70
|
+
// the real tracker — prints {exists, live, status} JSON; {ref} substituted.
|
|
71
|
+
board: { name: null, verify_cmd: null },
|
|
70
72
|
// Method gates (KJC-PCS-0068): rules climb from playbook text to
|
|
71
73
|
// deterministic enforcement. card_first: "auto" = block with hu-board
|
|
72
74
|
// (fully verifiable), warn with planning-game/external (presence only);
|
|
@@ -53,6 +53,7 @@ export async function pickStateBackend({ projectDir = process.cwd(), home = os.h
|
|
|
53
53
|
const backend = external ? "external" : value;
|
|
54
54
|
const boardName = external ? value.slice("external:".length) : null;
|
|
55
55
|
await persistStateBackend(projectDir, backend, boardName);
|
|
56
|
-
|
|
56
|
+
const suffix = boardName ? ` (${boardName})` : "";
|
|
57
|
+
logger.info?.(`✓ board: ${backend}${suffix} — persisted in .karajan/kj.config.yml`);
|
|
57
58
|
return { backend, boardName, source: "chosen" };
|
|
58
59
|
}
|
|
@@ -109,7 +109,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
109
109
|
"# POSIX-safe extraction (no grep -o): the ref is the leading",
|
|
110
110
|
"# card-shaped token of the branch's last segment (feat/REF-desc).",
|
|
111
111
|
"seg=$(printf '%s' \"$branch\" | tr '[:upper:]' '[:lower:]'); seg=\"${seg##*/}\"",
|
|
112
|
-
|
|
112
|
+
String.raw`ref=$(printf '%s' "$seg" | sed -nE 's/^([a-z][a-z0-9]{1,9}(-[a-z][a-z0-9]{1,9})*-[0-9]{1,6})(-.*)?$/\1/p')`,
|
|
113
113
|
'header=$(head -n1 "$msg_file")',
|
|
114
114
|
'if [ -n "$ref" ] && [ -n "$header" ]; then',
|
|
115
115
|
' case "$header" in',
|
|
@@ -122,7 +122,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
122
122
|
" stamp=$(printf '%s' \"$ref\" | tr '[:lower:]' '[:upper:]')",
|
|
123
123
|
' new_header="$header ($stamp)"',
|
|
124
124
|
' if [ "${#new_header}" -le 100 ]; then',
|
|
125
|
-
|
|
125
|
+
String.raw` { printf '%s\n' "$new_header"; tail -n +2 "$msg_file"; } > "$msg_file.kjtmp" && mv "$msg_file.kjtmp" "$msg_file"`,
|
|
126
126
|
" fi",
|
|
127
127
|
" fi",
|
|
128
128
|
" ;;",
|
|
@@ -49,6 +49,21 @@ export const branchOf = () => {
|
|
|
49
49
|
try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
|
|
50
50
|
catch { return null; }
|
|
51
51
|
};
|
|
52
|
+
const _git = (args, cwd) => {
|
|
53
|
+
try { return execSync("git " + args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
|
|
54
|
+
};
|
|
55
|
+
let _home;
|
|
56
|
+
// MONO-0 (ADR 0002): lane identity = worktree toplevel; repo identity =
|
|
57
|
+
// git-common-dir. Same repo but another toplevel than OUR root => foreign
|
|
58
|
+
// lane (returns its toplevel); anything else => null.
|
|
59
|
+
export const foreignLane = (targetDir) => {
|
|
60
|
+
const top = _git("rev-parse --show-toplevel", targetDir);
|
|
61
|
+
if (!top) return null;
|
|
62
|
+
_home ||= { top: _git("rev-parse --show-toplevel", ROOT), common: _git("rev-parse --path-format=absolute --git-common-dir", ROOT) };
|
|
63
|
+
if (!_home.top || top === _home.top) return null;
|
|
64
|
+
const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
|
|
65
|
+
return common && common === _home.common ? top : null;
|
|
66
|
+
};
|
|
52
67
|
export const violations = (s, branch) => {
|
|
53
68
|
const v = [];
|
|
54
69
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
@@ -177,9 +192,10 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
177
192
|
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
178
193
|
// remediation); read-only tools are never wired here; every honored escape
|
|
179
194
|
// is recorded as an auditable event. Fails OPEN on anything unexpected.
|
|
180
|
-
import { relative } from "node:path";
|
|
195
|
+
import { dirname, relative, resolve } from "node:path";
|
|
196
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
181
197
|
import { spawnSync } from "node:child_process";
|
|
182
|
-
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, load, violations, recordEscape } from "./sentinel-lib.mjs";
|
|
198
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape } from "./sentinel-lib.mjs";
|
|
183
199
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
184
200
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
185
201
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -210,6 +226,109 @@ process.stdin.on("end", () => {
|
|
|
210
226
|
process.exit(2);
|
|
211
227
|
}
|
|
212
228
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
229
|
+
// MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
|
|
230
|
+
// otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
|
|
231
|
+
const laneOf = (p) => {
|
|
232
|
+
// Sube al primer ancestro EXISTENTE (un subdir aun no creado escapaba).
|
|
233
|
+
let d = String(p);
|
|
234
|
+
if (!(existsSync(d) && statSync(d).isDirectory())) d = dirname(d);
|
|
235
|
+
while (d && !existsSync(d)) {
|
|
236
|
+
const up = dirname(d);
|
|
237
|
+
if (up === d) return null;
|
|
238
|
+
d = up;
|
|
239
|
+
}
|
|
240
|
+
// Canonicaliza (reviewer catch: un symlink local hacia otro carril
|
|
241
|
+
// esquivaba la clasificacion por ruta literal).
|
|
242
|
+
try { d = realpathSync(d); } catch { /* se clasifica tal cual */ }
|
|
243
|
+
return d ? foreignLane(d) : null;
|
|
244
|
+
};
|
|
245
|
+
const laneDeny = (what, lane) => {
|
|
246
|
+
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
|
|
247
|
+
console.error("kj 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.");
|
|
248
|
+
return true;
|
|
249
|
+
};
|
|
250
|
+
if (EDIT_TOOLS.includes(tool)) {
|
|
251
|
+
const target = input.file_path || input.notebook_path;
|
|
252
|
+
const lane = target ? laneOf(target) : null;
|
|
253
|
+
if (lane && laneDeny(String(target), lane)) process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
if (tool === "Bash") {
|
|
256
|
+
const cmd = String(input.command || "");
|
|
257
|
+
// Deny-unless-known-read-only: un allowlist de verbos mutadores era
|
|
258
|
+
// esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
|
|
259
|
+
// encadenar ni redirigir) puede nombrar otro carril; leer carriles es
|
|
260
|
+
// trabajo de las tools Read/Grep, como con los ficheros del supervisor.
|
|
261
|
+
// Solo LITERALES: expansión/sustitución en un comando "de lectura"
|
|
262
|
+
// puede ejecutar cualquier cosa (reviewer catch: grep foo $(rm ...)).
|
|
263
|
+
// (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
|
|
264
|
+
// carril los caza el escaner de abajo.)
|
|
265
|
+
const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
|
|
266
|
+
if (!READONLY.test(cmd)) {
|
|
267
|
+
// cd/pushd invalida TODO razonamiento textual de rutas posteriores
|
|
268
|
+
// (carrera de bypasses confirmada en review: destino bare, con $,
|
|
269
|
+
// relativas post-cd...): en un comando NO-read-only, cambiar de
|
|
270
|
+
// directorio se deniega conservadoramente — el remedio es no
|
|
271
|
+
// necesitarlo (git -C, npm --prefix, rutas absolutas).
|
|
272
|
+
if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
|
|
273
|
+
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
274
|
+
else {
|
|
275
|
+
console.error("kj 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).");
|
|
276
|
+
process.exit(2);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Absolute AND dot-relative tokens (../lane escapaba al primer scan —
|
|
280
|
+
// reviewer catch); relative se resuelve contra el cwd de la sesion.
|
|
281
|
+
// EVERY token (un tope seria un bypass); dedup acota el coste git.
|
|
282
|
+
// Fallo de solomon (seguridad no arbitrable) + carrera de bypasses:
|
|
283
|
+
// la expansion ($, backtick) solo se tolera en segmentos que son
|
|
284
|
+
// asignaciones puras o runners del toolchain propio (npm/pnpm/yarn/
|
|
285
|
+
// vitest/jest/kj/gh/echo/printf, git sin -C/--git-dir/--work-tree) —
|
|
286
|
+
// sus argumentos son datos. En herramientas genericas de fichero o
|
|
287
|
+
// interpretes el objetivo queda oculto al texto => deny. Los paths
|
|
288
|
+
// LITERALES (tambien dentro de sustituciones) los escanea el bucle
|
|
289
|
+
// de tokens de abajo; el residuo (expansion anidada en segmentos
|
|
290
|
+
// runner) queda documentado como en la regla de ficheros PROTECTED.
|
|
291
|
+
if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
|
|
292
|
+
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
293
|
+
else {
|
|
294
|
+
console.error("kj 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).");
|
|
295
|
+
process.exit(2);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
|
|
299
|
+
// en cualquier posicion, y una ruta entrecomillada con espacios es
|
|
300
|
+
// invisible al tokenizador: ambas => deny conservador (endpoint
|
|
301
|
+
// pedido por el reviewer; friccion asumida, el remedio es literal).
|
|
302
|
+
if (/\\$\\(|\`/.test(cmd) || /["'][^"'\\n]*\\/[^"'\\n]* [^"'\\n]*["']/.test(cmd)) {
|
|
303
|
+
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
304
|
+
else {
|
|
305
|
+
console.error("kj 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).");
|
|
306
|
+
process.exit(2);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
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]*$/;
|
|
310
|
+
const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
|
|
311
|
+
if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
312
|
+
if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
313
|
+
else {
|
|
314
|
+
console.error("kj 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).");
|
|
315
|
+
process.exit(2);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// ...incluidas relativas A PELO (los carriles kj viven en
|
|
319
|
+
// .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
|
|
320
|
+
// y foreignLane da null — solo deniega lo que CAE en otro carril.
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
for (const m of cmd.matchAll(/(?:^|[\\s"'=;(><])((?:\\.\\.?\\/)[^\\s"'\`;|&)]*|\\/[^\\s"'\`;|&)]+|~\\/[^\\s"'\`;|&)]+|[A-Za-z0-9_.@-]+\\/[^\\s"'\`;|&)]+)/g)) {
|
|
323
|
+
const t = m[1].startsWith("~/") ? (process.env.HOME || "~") + m[1].slice(1) : m[1];
|
|
324
|
+
const abs = resolve(t);
|
|
325
|
+
if (seen.has(abs)) continue;
|
|
326
|
+
seen.add(abs);
|
|
327
|
+
const lane = laneOf(abs);
|
|
328
|
+
if (lane && laneDeny(m[1], lane)) process.exit(2);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
213
332
|
if (EDIT_TOOLS.includes(tool)) {
|
|
214
333
|
const file = input.file_path || input.notebook_path;
|
|
215
334
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
@@ -26,6 +26,17 @@ const ALLOWED_COMMANDS = [
|
|
|
26
26
|
"bundle install", "composer install", "dotnet restore"
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
// KJC-BUG-0099: a dependency install must not be arbitrary code execution —
|
|
30
|
+
// lifecycle hooks (postinstall & friends) run whatever the dependency ships.
|
|
31
|
+
// The allow-list above names the INTENT; execution appends the ecosystem's
|
|
32
|
+
// script-skipping flag. Ecosystems without package hooks run untouched.
|
|
33
|
+
const HARDEN_FLAGS = {
|
|
34
|
+
npm: ["--ignore-scripts"],
|
|
35
|
+
pnpm: ["--ignore-scripts"],
|
|
36
|
+
yarn: ["--ignore-scripts"],
|
|
37
|
+
composer: ["--no-scripts", "--no-plugins"],
|
|
38
|
+
};
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* True when `target` resolves to a path inside `base` (or is `base` itself).
|
|
31
42
|
* Uses path.relative instead of a string prefix check: a naive
|
|
@@ -64,7 +75,7 @@ async function runCommand({ cmd, cwd }) {
|
|
|
64
75
|
// Even though `cmd` is allow-listed, defense-in-depth: switched to
|
|
65
76
|
// execFileSync with a tokenised arg array so no shell expansion at all.
|
|
66
77
|
const [program, ...args] = cmd.trim().split(/\s+/);
|
|
67
|
-
const output = execFileSync(program, args, {
|
|
78
|
+
const output = execFileSync(program, [...args, ...(HARDEN_FLAGS[program] ?? [])], {
|
|
68
79
|
cwd: cwd || process.cwd(),
|
|
69
80
|
encoding: "utf8",
|
|
70
81
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy engine — PL-A (KJC-TSK-0733, épica KJC-PCS-0074, ADR 0001).
|
|
3
|
+
* Policy as DATA (`.karajan/policy.yml`, vocabulario CERRADO), engine as
|
|
4
|
+
* code: módulo puro, determinista, sin red ni LLM. Una regla que el motor
|
|
5
|
+
* no puede aplicar falla FUERTE al cargar — jamás se ignora en silencio.
|
|
6
|
+
* Deny gana a allow; una allow-list convierte el exterior en denegación.
|
|
7
|
+
* Consumidores: kj policy (PL-A) → gates PL-B → CI PL-C → middleware PL-D.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, join, posix, relative } from "node:path";
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
import { commandPatternToRegExp, matchesAny } from "./glob.js";
|
|
13
|
+
|
|
14
|
+
// Vocabulario CERRADO e incremental: cada capacidad entra al set EN el PR
|
|
15
|
+
// que trae su evaluación — declararla antes sería una regla que miente.
|
|
16
|
+
const ROLE_CAPS = new Set(["write", "shell"]);
|
|
17
|
+
const INVARIANT_KINDS = new Set(["diff-threshold"]);
|
|
18
|
+
const INVARIANT_METRICS = new Set(["net_lines_added"]);
|
|
19
|
+
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
20
|
+
// Registro de tools conocidas (fallo de solomon: lo desconocido no se
|
|
21
|
+
// permite a un rol DECLARADO — crece por PR consciente).
|
|
22
|
+
const READONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]);
|
|
23
|
+
export const DEFAULT_POLICY = Object.freeze({ version: 1, roles: {}, invariants: [] });
|
|
24
|
+
|
|
25
|
+
function defaultReadFile(projectDir) {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(join(projectDir, ".karajan", "policy.yml"), "utf8");
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// Solo ENOENT es "sin policy"; EACCES/EIO seria un fallback silencioso.
|
|
30
|
+
if (err.code === "ENOENT") return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @returns {{policy: object, errors: string[]}} errors non-empty ⇒ the file
|
|
37
|
+
* declares something this engine cannot enforce — callers must surface it.
|
|
38
|
+
*/
|
|
39
|
+
export function loadPolicy({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
40
|
+
const { readFile = defaultReadFile } = deps;
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = readFile(projectDir);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { policy: DEFAULT_POLICY, errors: [`policy.yml: ilegible (${err.code || err.message}) — presente pero no leible NO es "sin policy"`] };
|
|
46
|
+
}
|
|
47
|
+
if (raw == null) return { policy: DEFAULT_POLICY, errors: [] };
|
|
48
|
+
|
|
49
|
+
const errors = [];
|
|
50
|
+
let doc;
|
|
51
|
+
try {
|
|
52
|
+
doc = yaml.load(raw) || {};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { policy: DEFAULT_POLICY, errors: [`policy.yml: YAML invalido — ${err.message}`] };
|
|
55
|
+
}
|
|
56
|
+
if (typeof doc !== "object" || Array.isArray(doc)) {
|
|
57
|
+
return { policy: DEFAULT_POLICY, errors: ["policy.yml: la raiz debe ser un objeto"] };
|
|
58
|
+
}
|
|
59
|
+
if (doc.version !== 1) {
|
|
60
|
+
errors.push(`policy.yml: version "${doc.version}" no soportada — este motor habla version 1`);
|
|
61
|
+
}
|
|
62
|
+
const roles = doc.roles ?? {};
|
|
63
|
+
if (typeof roles !== "object" || roles === null || Array.isArray(roles)) {
|
|
64
|
+
errors.push("policy.yml: roles debe ser un objeto");
|
|
65
|
+
} else {
|
|
66
|
+
validateRoles(roles, errors);
|
|
67
|
+
}
|
|
68
|
+
const invs = doc.invariants ?? [];
|
|
69
|
+
if (Array.isArray(invs)) {
|
|
70
|
+
for (const inv of invs) {
|
|
71
|
+
if (!INVARIANT_KINDS.has(inv?.kind)) {
|
|
72
|
+
errors.push(`policy.yml: invariant "${inv?.id}" con kind "${inv?.kind}" no existe en el vocabulario v1`);
|
|
73
|
+
} else if (!INVARIANT_METRICS.has(inv?.metric) || !Number.isFinite(inv?.max) || typeof inv?.id !== "string") {
|
|
74
|
+
errors.push(`policy.yml: invariant "${inv?.id}" requiere id string, metric (${[...INVARIANT_METRICS].join(", ")}) y max numérico`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
errors.push("policy.yml: invariants debe ser una lista");
|
|
79
|
+
}
|
|
80
|
+
return { policy: { roles: {}, invariants: [], ...doc }, errors };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateRoles(roles, errors) {
|
|
84
|
+
for (const [role, caps] of Object.entries(roles)) {
|
|
85
|
+
for (const [cap, spec] of Object.entries(caps || {})) {
|
|
86
|
+
if (!ROLE_CAPS.has(cap)) {
|
|
87
|
+
errors.push(`policy.yml: roles.${role}.${cap} no existe en el vocabulario (v1: ${[...ROLE_CAPS].join(", ")})`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
|
|
91
|
+
errors.push(`policy.yml: roles.${role}.${cap} debe ser un objeto {allow, deny}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
for (const key of Object.keys(spec)) {
|
|
95
|
+
if (key !== "allow" && key !== "deny") {
|
|
96
|
+
errors.push(`policy.yml: roles.${role}.${cap}.${key} no existe en el vocabulario (v1: allow, deny)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const list of ["allow", "deny"]) {
|
|
100
|
+
const v = spec?.[list];
|
|
101
|
+
if (v !== undefined && (!Array.isArray(v) || v.some((g) => typeof g !== "string"))) {
|
|
102
|
+
errors.push(`policy.yml: roles.${role}.${cap}.${list} debe ser una lista de strings (glob)`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const deny = (rule_id, reason) => ({ decision: "deny", rule_id, reason });
|
|
110
|
+
const ALLOW = Object.freeze({ decision: "allow" });
|
|
111
|
+
|
|
112
|
+
// Normaliza el destino a ruta RELATIVA canónica: absolutas se relativizan
|
|
113
|
+
// contra root (sin root ⇒ null), y `..`/escapes ⇒ null (reviewer catch:
|
|
114
|
+
// src/../.env no puede esquivar los globs, ni una absoluta salirse del root).
|
|
115
|
+
function normalizeTarget(filePath, root) {
|
|
116
|
+
let p = String(filePath).replaceAll("\\", "/");
|
|
117
|
+
if (isAbsolute(p)) {
|
|
118
|
+
if (!root) return null;
|
|
119
|
+
p = relative(root, p).replaceAll("\\", "/");
|
|
120
|
+
}
|
|
121
|
+
p = posix.normalize(p).replace(/^\.\//, "");
|
|
122
|
+
return p.startsWith("..") || isAbsolute(p) ? null : p;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function evalWrite(policy, role, filePath, root) {
|
|
126
|
+
const write = policy.roles?.[role]?.write;
|
|
127
|
+
if (!write) return ALLOW;
|
|
128
|
+
// Rol restringido + destino ausente/no-canonizable = no verificable ⇒ deny
|
|
129
|
+
// (reviewer catches: tool call malformado, traversal, absoluta sin root).
|
|
130
|
+
const p = filePath ? normalizeTarget(filePath, root) : null;
|
|
131
|
+
if (p == null) return deny(`roles.${role}.write`, `destino "${filePath ?? ""}" no verificable contra la policy del rol ${role}`);
|
|
132
|
+
if (matchesAny(p, write.deny)) {
|
|
133
|
+
return deny(`roles.${role}.write.deny`, `${p} esta denegado para el rol ${role}`);
|
|
134
|
+
}
|
|
135
|
+
if (Array.isArray(write.allow) && !matchesAny(p, write.allow)) {
|
|
136
|
+
return deny(`roles.${role}.write.allow`, `${p} esta fuera de la allow-list de escritura del rol ${role}`);
|
|
137
|
+
}
|
|
138
|
+
return ALLOW;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Launchers y asignaciones de entorno se PELAN antes de casar patrones
|
|
142
|
+
// (reviewer catch: "sudo git push" o "FOO=1 git add -A" no esquivan la
|
|
143
|
+
// regla del subcomando real); capas excesivas ⇒ opaco ⇒ deny.
|
|
144
|
+
const LAUNCHER_RE = /^(sudo(\s+-\S+)*|command|nohup|time|nice(\s+-n\s*\d+)?|stdbuf\s+\S+|env)\s+|^([A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/;
|
|
145
|
+
function stripLaunchers(seg) {
|
|
146
|
+
let s = seg;
|
|
147
|
+
for (let i = 0; i < 5; i++) {
|
|
148
|
+
const m = s.match(LAUNCHER_RE);
|
|
149
|
+
if (!m) return s;
|
|
150
|
+
// Asignaciones que alteran QUÉ ejecutable resuelve (PATH, LD_*, GIT_*,
|
|
151
|
+
// NODE_OPTIONS) o llevan expansión son opacas — no se pelan, denegan.
|
|
152
|
+
if (/^(PATH|LD_\w*|NODE_OPTIONS|GIT_\w*)=/.test(m[0]) || m[0].includes("$")) return null;
|
|
153
|
+
s = s.slice(m[0].length);
|
|
154
|
+
}
|
|
155
|
+
return null; // sigue envuelto tras 5 capas: no verificable
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tokenizador consciente de comillas: separa segmentos SOLO por operadores
|
|
159
|
+
// no entrecomillados (reviewer catch: un ';' dentro de un mensaje no es
|
|
160
|
+
// separador) y canonicaliza tokens sin comillas a la vez. Cualquier
|
|
161
|
+
// construcción que ejecute o redirija texto opaco al análisis — escapes,
|
|
162
|
+
// expansión ($ salvo entre comillas simples), sustitución/backticks,
|
|
163
|
+
// redirecciones, backgrounding, comillas sin cerrar — devuelve null.
|
|
164
|
+
function parseCommand(cmd) {
|
|
165
|
+
const segs = [];
|
|
166
|
+
let toks = [];
|
|
167
|
+
let cur = "";
|
|
168
|
+
let q = null;
|
|
169
|
+
const pushTok = () => {
|
|
170
|
+
if (cur) {
|
|
171
|
+
toks.push(cur);
|
|
172
|
+
cur = "";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const pushSeg = () => {
|
|
176
|
+
pushTok();
|
|
177
|
+
if (toks.length > 0) {
|
|
178
|
+
segs.push(toks.join(" "));
|
|
179
|
+
toks = [];
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const s = String(cmd);
|
|
183
|
+
for (let i = 0; i < s.length; i++) {
|
|
184
|
+
const ch = s[i];
|
|
185
|
+
if (ch === "\\" || ch === "`") return null;
|
|
186
|
+
if (ch === "$" && q !== "'") return null;
|
|
187
|
+
if (q) {
|
|
188
|
+
if (ch === q) q = null;
|
|
189
|
+
else cur += ch;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (ch === '"' || ch === "'") {
|
|
193
|
+
q = ch;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (ch === ">" || ch === "<") return null;
|
|
197
|
+
if (ch === "&" && s[i + 1] === "&") {
|
|
198
|
+
pushSeg();
|
|
199
|
+
i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "|" && s[i + 1] === "|") {
|
|
203
|
+
pushSeg();
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ch === "&") return null;
|
|
208
|
+
if (ch === ";" || ch === "|" || ch === "\n") {
|
|
209
|
+
pushSeg();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (/\s/.test(ch)) {
|
|
213
|
+
pushTok();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
cur += ch;
|
|
217
|
+
}
|
|
218
|
+
if (q) return null;
|
|
219
|
+
pushSeg();
|
|
220
|
+
return segs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Envolturas que re-ejecutan texto (lección del guard del Sentinel).
|
|
224
|
+
const WRAPPER_RE = /^(sh|bash|zsh|dash|ksh)\s+(-\S+\s+)*-\S*c(\s|$)|^eval\b|^xargs\b|^source\b|^\.\s/;
|
|
225
|
+
|
|
226
|
+
// Deny si CUALQUIER segmento casa; con allow-list, CADA segmento debe casar.
|
|
227
|
+
function evalShell(policy, role, command) {
|
|
228
|
+
const shell = policy.roles?.[role]?.shell;
|
|
229
|
+
if (!shell) return ALLOW;
|
|
230
|
+
if (!command) return deny(`roles.${role}.shell`, `comando ausente — no verificable para el rol ${role}`);
|
|
231
|
+
const segs = parseCommand(command);
|
|
232
|
+
if (segs == null) {
|
|
233
|
+
return deny(`roles.${role}.shell`, "construcciones opacas (escapes, expansión, sustitución, redirecciones, backgrounding o comillas sin cerrar) — no verificable");
|
|
234
|
+
}
|
|
235
|
+
for (const raw of segs) {
|
|
236
|
+
const seg = stripLaunchers(raw);
|
|
237
|
+
if (seg == null) return deny(`roles.${role}.shell`, `el segmento "${raw}" no es canonicalizable — no verificable`);
|
|
238
|
+
if (WRAPPER_RE.test(seg)) return deny(`roles.${role}.shell`, `el segmento "${seg}" re-ejecuta texto (envoltura/eval) — no verificable`);
|
|
239
|
+
const hit = Array.isArray(shell.deny) && shell.deny.find((p) => commandPatternToRegExp(p).test(seg));
|
|
240
|
+
if (hit) return deny(`roles.${role}.shell.deny`, `el segmento "${seg}" casa con el patrón denegado "${hit}"`);
|
|
241
|
+
if (Array.isArray(shell.allow) && !shell.allow.some((p) => commandPatternToRegExp(p).test(seg))) {
|
|
242
|
+
return deny(`roles.${role}.shell.allow`, `el segmento "${seg}" está fuera de la allow-list de shell del rol ${role}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return ALLOW;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One tool call against the policy. `root` permite evaluar file_path
|
|
249
|
+
* absolutos. Un rol DECLARADO solo usa tools del registro: lo desconocido
|
|
250
|
+
* es no-verificable ⇒ deny (roles sin declarar quedan como hoy). */
|
|
251
|
+
export function evalToolCall(policy, { role = "coder", tool, input = {}, root = null }) {
|
|
252
|
+
if (WRITE_TOOLS.has(tool)) return evalWrite(policy, role, input.file_path || input.notebook_path, root);
|
|
253
|
+
if (tool === "Bash") return evalShell(policy, role, input.command);
|
|
254
|
+
if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW;
|
|
255
|
+
return deny(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** El gate del RESULTADO: ficheros del diff + métricas contra rol e invariantes. */
|
|
259
|
+
export function checkStagedDiff(policy, { role = "coder", files = [], netLinesAdded = null } = {}) {
|
|
260
|
+
const violations = [];
|
|
261
|
+
for (const f of files) {
|
|
262
|
+
const r = evalWrite(policy, role, f);
|
|
263
|
+
if (r.decision === "deny") violations.push({ rule_id: r.rule_id, reason: r.reason, file: f });
|
|
264
|
+
}
|
|
265
|
+
for (const inv of policy.invariants || []) {
|
|
266
|
+
if (inv.kind !== "diff-threshold" || inv.metric !== "net_lines_added") continue;
|
|
267
|
+
if (!Number.isFinite(netLinesAdded)) {
|
|
268
|
+
// Métrica ausente con invariante declarado = no verificable, jamás un
|
|
269
|
+
// pase silencioso (reviewer catch).
|
|
270
|
+
violations.push({ rule_id: inv.id, reason: "net_lines_added no disponible — el invariante no es verificable sin la métrica" });
|
|
271
|
+
} else if (netLinesAdded > inv.max) {
|
|
272
|
+
violations.push({ rule_id: inv.id, reason: `net_lines_added=${netLinesAdded} supera el máximo ${inv.max}` });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return violations;
|
|
276
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// glob → RegExp anclada — primitivo de la policy layer (PL-A, KJC-TSK-0733).
|
|
2
|
+
// Semántica: doble asterisco cruza directorios; doble asterisco + barra es
|
|
3
|
+
// prefijo de directorio OPCIONAL (así "**" "/*.env*" casa también con .env
|
|
4
|
+
// en la raíz); "*" no sale de su segmento; "?" es un carácter. Sin
|
|
5
|
+
// dependencias: el vocabulario es pequeño a propósito — una lib general
|
|
6
|
+
// traería semánticas que la policy no declara.
|
|
7
|
+
export function globToRegExp(glob) {
|
|
8
|
+
let re = "";
|
|
9
|
+
const s = String(glob);
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < s.length) {
|
|
12
|
+
const c = s[i];
|
|
13
|
+
if (c === "*" && s[i + 1] === "*" && s[i + 2] === "/") {
|
|
14
|
+
re += "(?:.*/)?";
|
|
15
|
+
i += 3;
|
|
16
|
+
} else if (c === "*" && s[i + 1] === "*") {
|
|
17
|
+
re += ".*";
|
|
18
|
+
i += 2;
|
|
19
|
+
} else if (c === "*") {
|
|
20
|
+
re += "[^/]*";
|
|
21
|
+
i += 1;
|
|
22
|
+
} else if (c === "?") {
|
|
23
|
+
re += "[^/]";
|
|
24
|
+
i += 1;
|
|
25
|
+
} else {
|
|
26
|
+
re += c.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`);
|
|
27
|
+
i += 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return new RegExp(`^${re}$`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** true si `value` casa con ALGUNO de los globs (lista no-array ⇒ false). */
|
|
34
|
+
export const matchesAny = (value, globs) =>
|
|
35
|
+
Array.isArray(globs) && globs.some((g) => globToRegExp(g).test(value));
|
|
36
|
+
|
|
37
|
+
/** Patrón de comando shell → RegExp anclada al INICIO: `*` = cualquier cosa
|
|
38
|
+
* (los comandos no son rutas — sin semántica de segmentos). */
|
|
39
|
+
export function commandPatternToRegExp(pattern) {
|
|
40
|
+
const re = String(pattern)
|
|
41
|
+
.replaceAll(/[.+^${}()|[\]\\?]/g, String.raw`\$&`)
|
|
42
|
+
.replaceAll("*", ".*");
|
|
43
|
+
return new RegExp(`^${re}`);
|
|
44
|
+
}
|
package/src/review/card-first.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { listPlans, loadPlan } from "../plan/plan-store.js";
|
|
10
10
|
import { escapeRegExp } from "../utils/escape-regexp.js";
|
|
11
|
+
import { verifyCardAlive } from "./card-verify.js";
|
|
11
12
|
|
|
12
13
|
const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
|
|
13
14
|
// Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
|
|
@@ -44,7 +45,7 @@ async function findLiveCardInBranch(projectDir, branch) {
|
|
|
44
45
|
return closedHit;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env }) {
|
|
48
|
+
export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env, deps = {} }) {
|
|
48
49
|
if (env.KJ_ALLOW_NO_CARD === "1") {
|
|
49
50
|
return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
|
|
50
51
|
}
|
|
@@ -73,11 +74,24 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
|
|
|
73
74
|
: { ok: true, mode: "warn", reason };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// planning-game / external: presence check
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// planning-game / external: presence check, upgraded to LIVENESS against
|
|
78
|
+
// the real tracker when board.verify_cmd declares an adapter
|
|
79
|
+
// (KJC-TSK-0732, issue #1371). Unverifiable degrades honestly — the
|
|
80
|
+
// result names its level so nobody mistakes branch-ref for tracker.
|
|
80
81
|
const boardName = config.board?.name || backend;
|
|
82
|
+
const effective = policy === "auto" ? "warn" : policy;
|
|
83
|
+
if (CARD_REF_RE.test(branch)) {
|
|
84
|
+
const ref = branch.match(CARD_REF_RE)[0];
|
|
85
|
+
const { verifyFn = verifyCardAlive } = deps;
|
|
86
|
+
const v = await verifyFn({ ref, verifyCmd: config.board?.verify_cmd, projectDir });
|
|
87
|
+
if (v.verified === false) {
|
|
88
|
+
const reason = `branch references ${ref} but ${boardName} reports it "${v.status}" — card-first needs a live card in the tracker (create/reopen it there)`;
|
|
89
|
+
return effective === "block"
|
|
90
|
+
? { ok: false, mode: "block", reason, level: v.level }
|
|
91
|
+
: { ok: true, mode: "warn", reason, level: v.level };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, mode: "pass", ref, level: v.level, ...(v.note ? { note: v.note } : {}) };
|
|
94
|
+
}
|
|
81
95
|
const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
|
|
82
96
|
return effective === "block"
|
|
83
97
|
? { ok: false, mode: "block", reason }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* card-verify — KJC-TSK-0732 (issue #1371). With a declared tracker,
|
|
3
|
+
* card-first upgrades "the branch names a card" to "the tracker says that
|
|
4
|
+
* card is ALIVE". Transport-agnostic: `board.verify_cmd` is the USER'S
|
|
5
|
+
* adapter (their CLI/MCP wrapper — credentials never enter kj) printing
|
|
6
|
+
* {exists, live, status?} JSON on stdout. Unverifiable NEVER blocks: it
|
|
7
|
+
* degrades to branch-ref level SAYING SO — a gate must not claim a check
|
|
8
|
+
* it didn't make, in either direction. Definitive verdicts are cached
|
|
9
|
+
* briefly; degradations never are, so recovery is immediate.
|
|
10
|
+
*/
|
|
11
|
+
import { exec } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
17
|
+
|
|
18
|
+
const execAsync = promisify(exec);
|
|
19
|
+
export const VERIFY_TIMEOUT_MS = 4000;
|
|
20
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
21
|
+
// The ref lands inside a shell command (the user's adapter template), so it
|
|
22
|
+
// must be inert BY CONSTRUCTION — card-ref alphabet only, anchored (codex
|
|
23
|
+
// catch: same injection class as the tournament coder names).
|
|
24
|
+
const SAFE_REF_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i;
|
|
25
|
+
|
|
26
|
+
const defaultExec = async (cmd, { cwd }) =>
|
|
27
|
+
execAsync(cmd, { cwd, timeout: VERIFY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
|
|
28
|
+
|
|
29
|
+
function cachePath(projectDir) {
|
|
30
|
+
return path.join(projectDir, ".kj", "cache", "card-verify.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @returns {Promise<{level: "tracker"|"branch-ref", verified: boolean|null, status?: string|null, note?: string}>}
|
|
34
|
+
* verified: true = alive · false = definitively not · null = degraded. */
|
|
35
|
+
export async function verifyCardAlive({ ref, verifyCmd, projectDir = process.cwd(), deps = {} }) {
|
|
36
|
+
const { execFn = defaultExec, now = Date.now } = deps;
|
|
37
|
+
if (!verifyCmd) {
|
|
38
|
+
return {
|
|
39
|
+
level: "branch-ref", verified: null,
|
|
40
|
+
note: "card-first checked the branch reference only — set board.verify_cmd to verify the card against your tracker",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!SAFE_REF_RE.test(String(ref))) {
|
|
45
|
+
return {
|
|
46
|
+
level: "branch-ref", verified: null,
|
|
47
|
+
note: `tracker verification skipped — ref "${String(ref).slice(0, 30)}" is outside the card-ref alphabet`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
let cache = {};
|
|
51
|
+
try {
|
|
52
|
+
cache = JSON.parse(fs.readFileSync(cachePath(projectDir), "utf8"));
|
|
53
|
+
} catch { /* no cache yet */ }
|
|
54
|
+
// Keyed by adapter AND ref (reviewer catch): a verdict from one
|
|
55
|
+
// verify_cmd must never answer for a different one.
|
|
56
|
+
const key = `${createHash("sha256").update(verifyCmd).digest("hex").slice(0, 12)}:${ref}`;
|
|
57
|
+
const hit = cache[key];
|
|
58
|
+
if (hit && now() - hit.at < CACHE_TTL_MS) return hit.result;
|
|
59
|
+
|
|
60
|
+
const cmd = verifyCmd.includes("{ref}") ? verifyCmd.replaceAll("{ref}", ref) : `${verifyCmd} ${ref}`;
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFn(cmd, { cwd: projectDir });
|
|
63
|
+
const parsed = extractFirstJson(String(stdout));
|
|
64
|
+
if (!parsed || typeof parsed.exists !== "boolean") {
|
|
65
|
+
throw new Error("adapter must print JSON {exists, live, status} on stdout");
|
|
66
|
+
}
|
|
67
|
+
const alive = parsed.exists && parsed.live !== false;
|
|
68
|
+
const result = alive
|
|
69
|
+
? { level: "tracker", verified: true, status: parsed.status ?? null }
|
|
70
|
+
: { level: "tracker", verified: false, status: parsed.status ?? (parsed.exists ? "not-live" : "not-found") };
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(path.dirname(cachePath(projectDir)), { recursive: true });
|
|
73
|
+
fs.writeFileSync(cachePath(projectDir), JSON.stringify({ ...cache, [key]: { at: now(), result } }));
|
|
74
|
+
} catch { /* cache is best-effort — the verdict stands without it */ }
|
|
75
|
+
return result;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const why = String(err.message || err).split("\n")[0].slice(0, 120);
|
|
78
|
+
return {
|
|
79
|
+
level: "branch-ref", verified: null,
|
|
80
|
+
note: `tracker verification degraded to branch-ref (${why}) — fix board.verify_cmd or its credentials`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -47,13 +47,17 @@ export function buildScannerOpts(projectKey, scanner = {}) {
|
|
|
47
47
|
opts.push(`-Dsonar.javascript.lcov.reportPaths=${scanner.javascript_lcov_report_paths}`);
|
|
48
48
|
}
|
|
49
49
|
const rules = scanner.disabled_rules || [];
|
|
50
|
-
rules.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
if (rules.length > 0) {
|
|
51
|
+
// KJC-BUG-0139: the list property is declared ONCE — one `multicriteria=`
|
|
52
|
+
// per rule was last-wins, silently un-ignoring every rule but the final one.
|
|
53
|
+
opts.push(`-Dsonar.issue.ignore.multicriteria=${rules.map((_, i) => `e${i + 1}`).join(",")}`);
|
|
54
|
+
rules.forEach((rule, i) => {
|
|
55
|
+
opts.push(
|
|
56
|
+
`-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`,
|
|
57
|
+
`-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
57
61
|
return opts.join(" ");
|
|
58
62
|
}
|
|
59
63
|
|
package/src/tournament/judge.js
CHANGED
|
@@ -123,9 +123,8 @@ export async function judgeTournament({ tournamentDir, config = {}, logger = con
|
|
|
123
123
|
let solomon = null;
|
|
124
124
|
if (escalated) {
|
|
125
125
|
if (!solomonFn) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
);
|
|
126
|
+
const why = verdict.tie ? "empate declarado" : `discrepancia juez(${judgeTop}) vs scoreboard(${boardTop})`;
|
|
127
|
+
throw new Error(`tournament: ${why} y sin árbitro configurado`);
|
|
129
128
|
}
|
|
130
129
|
solomon = await solomonFn({
|
|
131
130
|
judgeRanking: verdict.ranking,
|
|
@@ -17,7 +17,7 @@ import { execFile } from "node:child_process";
|
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
18
|
|
|
19
19
|
const execFileAsync = promisify(execFile);
|
|
20
|
-
const TESTS_RE = /(
|
|
20
|
+
const TESTS_RE = /(?:(?:^|\/)(?:tests?|__tests__|spec)\/)|(?:\.(?:test|spec)\.[a-z]+$)/;
|
|
21
21
|
const MUTATE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
22
|
|
|
23
23
|
// EXACT unified-diff header forms — an added line whose content starts with
|
|
@@ -17,7 +17,8 @@ const HIGHLIGHT_MAX = 160;
|
|
|
17
17
|
* ("Minor. **The method, enforced.** …"), bold stripped, truncated. Pure.
|
|
18
18
|
*/
|
|
19
19
|
export function parseChangelogHighlight(markdown, version) {
|
|
20
|
-
const
|
|
20
|
+
const escapedVersion = version.replaceAll(".", String.raw`\.`);
|
|
21
|
+
const section = String(markdown).split(new RegExp(String.raw`^## \[${escapedVersion}\][^\n]*$`, "m"))[1];
|
|
21
22
|
if (!section) return null;
|
|
22
23
|
const firstLine = section.split(/^##|^###/m)[0].split("\n").map((l) => l.trim()).find(Boolean);
|
|
23
24
|
if (!firstLine) return null;
|