karajan-code 4.55.0 → 4.56.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.55.0",
3
+ "version": "4.56.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -15,6 +15,7 @@ import { statusCommand } from "../commands/status.js";
15
15
  import { loadPlan } from "../plan/plan-store.js";
16
16
  import { withConfig } from "./_shared.js";
17
17
  import { publishPrReview } from "../review/publish-pr.js";
18
+ import { thirdPartyReviewCommand } from "../review/third-party-review.js";
18
19
 
19
20
  /**
20
21
  * Register the "core pipeline" commands: everything the user runs day-to-day
@@ -257,8 +258,15 @@ export function registerPipeline(program, { pkgVersion }) {
257
258
  .option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
258
259
  .option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
259
260
  .option("--publish-pr <n>", "Publish the cross-AI verdicts of a PR's commits on it as one GitHub review")
261
+ .option("--third-party", "Review someone else's PR (--range base...head): reviewer only, no own-work gates, no verdict stored")
262
+ .option("--json", "With --third-party: print the review as one JSON document on stdout")
260
263
  .action(async (task, flags) => {
261
264
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
265
+ // KJC-TSK-1075 (#2227): a third-party PR, for reviewer agents; own work keeps every gate.
266
+ if (flags.thirdParty) {
267
+ process.exitCode = await thirdPartyReviewCommand({ flags: { ...flags, task: task ?? null }, config });
268
+ return;
269
+ }
262
270
  // KJC-TSK-0913: the verdicts kj already holds, published on the PR as one review.
263
271
  if (flags.publishPr) {
264
272
  const r = await publishPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.publishPr });
@@ -16,6 +16,7 @@ import { interactiveHarden } from "../harden/interactive.js";
16
16
  import { createWizard, isTTY } from "../utils/wizard.js";
17
17
  import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
18
18
  import { installConfigsForRoots } from "../harden/config-engine.js";
19
+ import { readHardenConfig } from "../harden/harden-scope.js";
19
20
  import { installGuidelines } from "../harden/guidelines-engine.js";
20
21
  import { maybeRulesyncGenerate } from "../utils/rulesync.js";
21
22
  import { commandsForLanguage } from "../harden/hook-commands.js";
@@ -35,21 +36,6 @@ const JS_TEST_CMD = {
35
36
  mocha: "npx mocha",
36
37
  };
37
38
 
38
- /**
39
- * Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
40
- * sub-tools (e.g. a python wrapper) excludes them once instead of on every
41
- * invocation (KJC-TSK-0564). CLI flags are merged on top.
42
- */
43
- function readHardenConfig(projectDir) {
44
- const pkgPath = join(projectDir, "package.json");
45
- if (!existsSync(pkgPath)) return {};
46
- try {
47
- return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
48
- } catch {
49
- return {};
50
- }
51
- }
52
-
53
39
  /**
54
40
  * Resolve the hook's lint/format/test commands for the primary language.
55
41
  * Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
@@ -38,7 +38,7 @@ async function indexFiles({ files, projectDir, db, embedder, slug, logger, json,
38
38
  logger.warn?.(`[rag] ${out}: not indexed`);
39
39
  continue;
40
40
  }
41
- const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
41
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
42
42
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
43
43
  }
44
44
  const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
@@ -26,6 +26,8 @@ import { loadPrivacyList, scanText } from "../privacy/scan.js";
26
26
  import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
27
27
  import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
28
28
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
29
+ import { prSizeLimit } from "../review/pr-requirements.js";
30
+ import { stewardBlocking } from "../steward/gate.js";
29
31
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
30
32
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
31
33
  import { evaluatePolicyGate } from "../review/policy-gate.js";
@@ -227,6 +229,14 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
227
229
  const card = await enforceCardFirst({ config, projectDir });
228
230
  if (!card.ok) return { verdict: "rejected", reviewer: "card-first", issues: [{ severity: "high", description: card.reason }] };
229
231
 
232
+ // KJC-TSK-1086: the Steward's block at commit too, not only where Edit starts work.
233
+ const steward = stewardBlocking({ projectDir, config });
234
+ if (steward) {
235
+ console.log(`✗ ${steward.reason}`);
236
+ process.exitCode = 1;
237
+ return { verdict: "rejected", reviewer: "steward", issues: [{ severity: "high", description: steward.reason }] };
238
+ }
239
+
230
240
  // KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
231
241
  // single test change warn (block via method_gates.tests_with_code).
232
242
  const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
@@ -246,16 +256,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
246
256
  // PL-C (KJC-TSK-0735), fuente única de umbrales: si la policy declara el
247
257
  // invariante net_lines_added, SU max es el umbral — el mismo número que
248
258
  // aplican kj policy check y el tier C de CI; method_gates queda de fallback.
249
- const pol = loadPolicy({ projectDir });
250
- const locInv = pol.errors.length === 0
251
- ? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
252
- : null;
253
259
  // KJC-BUG-0326 (#2085): two tiers. ~150 is the ideal (method_gates.pr_size_warn);
254
260
  // up to the limit (the policy's net_lines_added max, else method_gates.pr_size_max,
255
- // 200) is allowed without asking; above the limit, partition.
256
- const sizeIdeal = config?.method_gates?.pr_size_warn ?? 150;
257
- const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_max ?? 200;
258
- const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
261
+ // 200) is allowed without asking; above the limit, partition. KJC-TSK-1076: one
262
+ // source, shared with the third-party PR checks.
263
+ const pol = loadPolicy({ projectDir });
264
+ const { ideal: sizeIdeal, limit: sizeWarn, source: sizeSource } = prSizeLimit({ projectDir, config });
259
265
  if (sizeWarn > 0) {
260
266
  const numstat = await rawDiff(flags.range, ["--numstat"]);
261
267
  // KJC-BUG-0205: the budget caps CODE growth, so generated output and human
@@ -11,8 +11,9 @@ import { existsSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  import { runCommand } from "../utils/process.js";
14
- import { artifactIdForConfig, findAlternative } from "./alternatives.js";
14
+ import { configLeftAlone } from "./config-engine.js";
15
15
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
+ import { readHardenConfig } from "./harden-scope.js";
16
17
  import { PROFILE_HOOKS } from "./hook-templates.js";
17
18
  import { detectStackRoots } from "./stack-roots.js";
18
19
  import { qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
@@ -49,24 +50,24 @@ export async function checkHarden({ projectDir = process.cwd(), profile = "stand
49
50
  // Config + workflows ship with standard+ — and surface the greenfield gap:
50
51
  // a language now present but never hardened ⇒ its config shows as missing.
51
52
  if (profile !== "minimal") {
52
- const roots = detectStackRoots(projectDir);
53
+ // KJC-BUG-0370: the same scope (package.json kj.harden) and the same verdict on a
54
+ // missing config as kj harden, so check never calls drift what harden leaves alone.
55
+ const { only = [], exclude = [] } = readHardenConfig(projectDir);
56
+ const roots = detectStackRoots(projectDir, { only, exclude });
57
+ const configCheck = (cfg, rel, altDirs, missing) => {
58
+ const leftAlone = existsSync(join(projectDir, rel)) ? null : configLeftAlone(cfg, altDirs);
59
+ if (!leftAlone) return presence(projectDir, rel, `config:${rel}`, missing);
60
+ const detail = leftAlone.action === "covered" ? `covered by ${leftAlone.by}` : `omitted: ${leftAlone.note}`;
61
+ return { id: `config:${rel}`, ok: true, detail };
62
+ };
53
63
  for (const cfg of UNIVERSAL_CONFIGS) {
54
- checks.push(presence(projectDir, cfg.file, `config:${cfg.file}`, "missing — run kj harden"));
64
+ checks.push(configCheck(cfg, cfg.file, [projectDir], "missing — run kj harden"));
55
65
  }
56
66
  for (const { dir, language } of roots) {
57
67
  const rootDir = dir === "." ? projectDir : join(projectDir, dir);
58
68
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
59
69
  const rel = dir === "." ? cfg.file : join(dir, cfg.file);
60
- // A config covered by the user's own tool (biome.json ⇒ eslint+prettier)
61
- // is not drift — kj must not demand a second linter/formatter (KJC-TSK-0614).
62
- if (!existsSync(join(projectDir, rel))) {
63
- const alt = findAlternative([rootDir, projectDir], artifactIdForConfig(cfg));
64
- if (alt) {
65
- checks.push({ id: `config:${rel}`, ok: true, detail: `covered by ${alt.foundAt} (${alt.tool})` });
66
- continue;
67
- }
68
- }
69
- checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
70
+ checks.push(configCheck(cfg, rel, [rootDir, projectDir], `missing for ${language} — run kj harden`));
70
71
  }
71
72
  }
72
73
  const expectedWf = [...WORKFLOWS];
@@ -33,6 +33,27 @@ function hasTool(dirs, tool) {
33
33
  return false;
34
34
  }
35
35
 
36
+ /**
37
+ * Why kj leaves a missing config alone, or null when it seeds it. KJC-BUG-0370:
38
+ * kj check reads the same answer, so it never calls drift what harden skips.
39
+ */
40
+ export function configLeftAlone(cfg, altDirs) {
41
+ const id = artifactIdForConfig(cfg);
42
+ const alt = findAlternative(altDirs, id);
43
+ if (alt) return { action: "covered", by: alt.foundAt };
44
+ // KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
45
+ // package.json#eslintConfig…) means the config already EXISTS — seeding
46
+ // kj's default filename next to it would silently ECLIPSE the user's
47
+ // real config (eslint resolves eslint.config.js before .mjs).
48
+ const variant = findEquivalentIn(altDirs, id);
49
+ if (variant) return { action: "covered", by: variant };
50
+ // KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
51
+ if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
52
+ return { action: "omitted", note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden` };
53
+ }
54
+ return null;
55
+ }
56
+
36
57
  function writeFile(target, content) {
37
58
  mkdirSync(dirname(target), { recursive: true });
38
59
  writeFileSync(target, content);
@@ -50,30 +71,10 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
50
71
  const exists = existsSync(target);
51
72
  const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
52
73
 
53
- if (!exists) {
54
- const alt = findAlternative(altDirs, artifactIdForConfig(cfg));
55
- if (alt) {
56
- results.push({ file, action: "covered", by: alt.foundAt });
57
- continue;
58
- }
59
- // KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
60
- // package.json#eslintConfig…) means the config already EXISTS — seeding
61
- // kj's default filename next to it would silently ECLIPSE the user's
62
- // real config (eslint resolves eslint.config.js before .mjs).
63
- const variant = findEquivalentIn(altDirs, artifactIdForConfig(cfg));
64
- if (variant) {
65
- results.push({ file, action: "covered", by: variant });
66
- continue;
67
- }
68
- // KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
69
- if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
70
- results.push({
71
- file,
72
- action: "omitted",
73
- note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden`,
74
- });
75
- continue;
76
- }
74
+ const leftAlone = exists ? null : configLeftAlone(cfg, altDirs);
75
+ if (leftAlone) {
76
+ results.push({ file, ...leftAlone });
77
+ continue;
77
78
  }
78
79
 
79
80
  if (cfg.json) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
3
+ * sub-tools (e.g. a python wrapper) excludes them once instead of on every
4
+ * invocation (KJC-TSK-0564). KJC-BUG-0370: kj harden and kj check read it from
5
+ * here, so check never asks for configs harden was told to leave out.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export function readHardenConfig(projectDir) {
11
+ const pkgPath = join(projectDir, "package.json");
12
+ if (!existsSync(pkgPath)) return {};
13
+ try {
14
+ return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
@@ -10,8 +10,9 @@
10
10
 
11
11
  import { homedir } from "node:os";
12
12
 
13
- const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
14
- const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
13
+ // KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
14
+ export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
15
+ export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
15
16
 
16
17
  export const SHEBANG = "#!/usr/bin/env sh";
17
18
 
@@ -45,10 +46,12 @@ export function toPortableHooksDir(dir, home = homedir()) {
45
46
  function chainToGlobal(hook, globalHooksDir) {
46
47
  if (!globalHooksDir) return [];
47
48
  const dir = toPortableHooksDir(globalHooksDir);
49
+ // KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
50
+ const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "${dir}/${hook}" "$@"; rc=$?` : `"${dir}/${hook}" "$@"; rc=$?`;
48
51
  return [
49
52
  "# Chain the machine's previous global hook (kj harden keeps it active).",
50
53
  `if [ -x "${dir}/${hook}" ]; then`,
51
- ` "${dir}/${hook}" "$@"; rc=$?`,
54
+ ` ${call}`,
52
55
  ' [ "$rc" -eq 0 ] || exit "$rc"',
53
56
  "fi",
54
57
  ];
@@ -100,6 +103,22 @@ function identityGuard(hook) {
100
103
  }
101
104
  return [
102
105
  ...head,
106
+ // KJC-TSK-1085: every commit the push brings carries the declared identity
107
+ // (a rebase, cherry-pick, merge or tag never ran pre-commit).
108
+ " kj_declared_email=$(sed -n 's/^git_email:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
109
+ ' if [ -n "$kj_declared_email" ]; then',
110
+ ' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
111
+ ' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
112
+ // A new ref: what the PUSH TARGET ($1, the remote git hands the hook) does not have yet.
113
+ ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_rsha..$kj_lsha") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
114
+ " for kj_c in $kj_new; do",
115
+ ' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
116
+ ' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
117
+ ' echo "kj harden: identity lock — commit $(git log -1 --format=%h "$kj_c") is authored/committed as $kj_who but this clone is declared as $kj_declared_email"; exit 1',
118
+ " fi",
119
+ " done",
120
+ " done || exit 1",
121
+ " fi",
103
122
  " kj_declared_gh=$(sed -n 's/^gh_user:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
104
123
  ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
105
124
  " 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)",
@@ -178,6 +197,8 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
178
197
  ].join("\n");
179
198
  case "pre-push": {
180
199
  const lines = [
200
+ // KJC-TSK-1085: git hands the pushed refs on stdin; kept for the identity check and the chained hook.
201
+ "[ -t 0 ] || kj_push_refs=$(cat)",
181
202
  "# Identity guard — never push without a configured git identity.",
182
203
  'if [ -z "$(git config user.email)" ] || [ -z "$(git config user.name)" ]; then',
183
204
  " echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
@@ -8,18 +8,36 @@
8
8
  * 4. a random nonce typed back, read from the real controlling terminal.
9
9
  */
10
10
  import { randomBytes } from "node:crypto";
11
- import { closeSync, openSync, readFileSync, readSync } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
12
13
 
13
14
  const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
14
15
 
15
- export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
16
- const read = readProc || ((p) => {
17
- const stat = readFileSync(`/proc/${p}/stat`, "utf8");
18
- const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
19
- let cmd = "";
20
- try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
21
- return { ppid, cmd };
22
- });
16
+ const procRead = (p) => {
17
+ const stat = readFileSync(`/proc/${p}/stat`, "utf8");
18
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
19
+ let cmd = "";
20
+ try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
21
+ return { ppid, cmd };
22
+ };
23
+
24
+ // KJC-TSK-1084: without /proc (macOS) the chain comes from ps, so a human
25
+ // there is not refused for an ancestry nobody could read.
26
+ // The system binary by absolute path: a ps resolved through PATH could be the
27
+ // agent's own forgery prepended to it.
28
+ const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? null;
29
+ const psOutput = (p) => {
30
+ if (!PS_BIN) throw new Error("no system ps (/bin/ps, /usr/bin/ps)");
31
+ return execFileSync(PS_BIN, ["-o", "ppid=", "-o", "command=", "-p", String(p)], { encoding: "utf8", env: {} });
32
+ };
33
+ const psRead = (ps) => (p) => {
34
+ const m = /^\s*(\d+)\s+(.*)$/s.exec(String(ps(p) ?? ""));
35
+ if (!m) throw new Error(`ps gave nothing for pid ${p}`);
36
+ return { ppid: Number(m[1]), cmd: m[2].trim() };
37
+ };
38
+
39
+ export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40, hasProc = existsSync("/proc/self/stat"), ps = psOutput } = {}) {
40
+ const read = readProc || (hasProc ? procRead : psRead(ps));
23
41
  let cur = pid;
24
42
  for (let i = 0; i < maxDepth && cur > 1; i += 1) {
25
43
  let info;
@@ -37,6 +55,11 @@ export function refuseAgentSession(what, { env = process.env, tty = process.stdo
37
55
  throw new Error(`${what} es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)`);
38
56
  }
39
57
  const anc = agentAncestry(ancestry);
58
+ // KJC-TSK-1084: fail closed. Nothing else stops a human act any more (the
59
+ // Sentinel does not read its text), so an ancestry that cannot be read is no pass.
60
+ if (anc.unknown) {
61
+ throw new Error(`${what} es un acto humano y no se pudo comprobar la ascendencia de procesos — córrelo desde TU terminal (ADR 0009)`);
62
+ }
40
63
  if (anc.agent) {
41
64
  throw new Error(`${what} es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`);
42
65
  }
@@ -475,7 +475,7 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
475
475
  import { spawnSync } from "node:child_process";
476
476
  import { homedir } from "node:os";
477
477
  import { fileURLToPath } from "node:url";
478
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
478
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
479
479
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
480
480
  import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
481
481
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
@@ -485,7 +485,6 @@ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[
485
485
  // KJC-BUG-0333: a publish is read per simple command by the shell reader, so
486
486
  // words inside a quoted argument of another command are not a publish.
487
487
  const isPublish = isPublishCommand;
488
- const PUSH = /\\bgit\\s+push\\b/;
489
488
  const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
490
489
  let raw = "";
491
490
  process.stdin.on("data", (d) => { raw += d; });
@@ -606,32 +605,17 @@ process.stdin.on("end", () => {
606
605
  }
607
606
  }
608
607
  }
609
- // KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
610
- // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
611
- // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
608
+ // KJC-TSK-0822 v1.1: la clave del movil (capa 5) no se toca desde la sesion.
609
+ // KJC-TSK-1084 (ADR 0015): los comandos de acto humano (kj harden --commit,
610
+ // kj identity enroll-phone) ya no se leen aqui: la propia orden rechaza un
611
+ // proceso que desciende de un agente (src/harden/human-act.js). Queda el
612
+ // FICHERO de la clave, que solo esto protege hasta que el sandbox lo vete.
612
613
  {
613
- // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es enrolar.
614
- const enrollCmd = stripTextOnlyCommandValues(String(input.command || ""));
615
- const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
614
+ // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es escribirlo.
615
+ const namesPhoneFile = stripTextOnlyCommandValues(String(input.command || "")).includes("supervisor-phone.json");
616
616
  const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
617
- if ((tool === "Bash" && namesEnroll) || writesEnroll) {
618
- console.error("karajan sentinel: el enrolamiento del movil (capa 5) es un acto humano — pideselo a tu usuario; supervisor-phone.json no se toca desde la sesion." + doc("supervisor"));
619
- process.exit(2);
620
- }
621
- }
622
- // ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
623
- // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
624
- // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
625
- // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
626
- // KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
627
- // git commit, que solo guardan texto (decision del usuario, 16-oct).
628
- // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
629
- // arriba: un env var lo pone tambien un agente — el supervisor no se
630
- // apaga con interruptores al alcance del supervisado.
631
- {
632
- const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
633
- if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
634
- console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
617
+ if ((tool === "Bash" && namesPhoneFile) || writesEnroll) {
618
+ console.error("karajan sentinel: la clave del movil (capa 5) es del humano — supervisor-phone.json no se toca desde la sesion; enrolar es kj identity enroll-phone desde su terminal." + doc("supervisor"));
635
619
  process.exit(2);
636
620
  }
637
621
  }
@@ -676,7 +660,6 @@ process.stdin.on("end", () => {
676
660
  return v.length > 0 ? v : null;
677
661
  };
678
662
  const declaredGh = idLine("gh_user");
679
- const declaredEmail = idLine("git_email");
680
663
  const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
681
664
  const activeGh = () => {
682
665
  if (!existsSync(ghHostsPath)) return null;
@@ -699,11 +682,11 @@ process.stdin.on("end", () => {
699
682
  // $( and backticks are separators too (review catch): their payload is its own segment.
700
683
  const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
701
684
  const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
702
- const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth, words(seg)); };
703
- const checkSeg = (ws, depth, raw) => {
685
+ const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
686
+ const checkSeg = (ws, depth) => {
704
687
  if (ws.length === 0) return;
705
- if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/git/wrapper token (review catch)
706
- const j = ws.findIndex((w, i) => i > 0 && ["gh", "git", ...WRAPPERS].includes(bare(w)));
688
+ if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
689
+ const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
707
690
  if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
708
691
  return;
709
692
  }
@@ -715,46 +698,9 @@ process.stdin.on("end", () => {
715
698
  if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
716
699
  const eff = switchedTo || activeGh();
717
700
  if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
718
- return;
719
- }
720
- if (ws[0] === "git") {
721
- // Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
722
- // are forwarded verbatim to git var, so git resolves identity exactly as the real
723
- // command would (review catches: -C cumulative, --git-dir, --opt=value forms).
724
- const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
725
- let k = 1;
726
- while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
727
- const globals = ws.slice(1, k).map(bare);
728
- const sub = ws[k];
729
- if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
730
- if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
731
- const eff = switchedTo || activeGh();
732
- if (eff !== declaredGh) problems.push("git push with gh session '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix: gh auth switch --user " + declaredGh + " && ...");
733
- return;
734
- }
735
- // IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
736
- if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
737
- if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
738
- // Let GIT resolve author and committer under the command's own environment
739
- // (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
740
- // its business): git var is the single source of truth.
741
- const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
742
- const env = Object.assign({}, process.env);
743
- for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
744
- const ident = (v) => {
745
- const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
746
- const s = r.status === 0 ? String(r.stdout) : "";
747
- const a = s.indexOf("<");
748
- const b = s.indexOf(">");
749
- return a >= 0 && b > a ? s.slice(a + 1, b) : null;
750
- };
751
- const ai = ws.findIndex((w) => w.startsWith("--author"));
752
- const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
753
- const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
754
- const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
755
- const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
756
- if (offender !== null) problems.push("git " + sub + " as '" + offender + "' but this clone is declared as '" + declaredEmail + "' — use: git -c user.email=" + declaredEmail + " " + sub + " ... (or git config user.email " + declaredEmail + ")");
757
701
  }
702
+ // KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
703
+ // pre-commit checks the author, pre-push every pushed commit and the gh session.
758
704
  };
759
705
  scan(CMD_TEXT, 0);
760
706
  if (problems.length > 0) {
@@ -1177,17 +1123,14 @@ process.stdin.on("end", () => {
1177
1123
  }
1178
1124
  }
1179
1125
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
1180
- 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)
1126
+ // KJC-TSK-1089: git push is not read any more; pending moves still stop the turn (stop gate).
1127
+ const ADVANCE = /\\bgit\\s+commit\\b|\\bgh\\s+pr\\s+create\\b/;
1181
1128
  const mergeM = cmd.match(MERGE);
1182
1129
  const pend = pendingMoves(load().sessions?.[sid]);
1183
- // KJC-BUG-0364: one board-gate decision per command; the push gate below
1184
- // reuses it, so a second slow gh cannot contradict the first answer.
1185
- let boardDecision = null;
1186
1130
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1187
1131
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1188
1132
  // por su primera mitad (boardGate, en la lib).
1189
- boardDecision = boardGate(sid, pend);
1190
- const { blocking, carried, reason } = boardDecision;
1133
+ const { blocking, carried, reason } = boardGate(sid, pend);
1191
1134
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1192
1135
  // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1193
1136
  // are already decided above (KJC-BUG-0230, 0198).
@@ -1234,18 +1177,10 @@ process.stdin.on("end", () => {
1234
1177
  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"));
1235
1178
  process.exit(2);
1236
1179
  }
1237
- } else if (PUSH.test(cmd)) {
1238
- // KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
1239
- // ADR 0015 (KJC-TSK-0925): with no escape.
1240
- // KJC-BUG-0262: a carried card does not block the push the commit let through.
1241
- const sess = load().sessions?.[sid];
1242
- const decision = boardDecision || boardGate(sid, pendingMoves(sess));
1243
- const v = violations(sess, branchOf(), decision.blocking);
1244
- if (v.length) {
1245
- console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + (decision.reason ? "\\n(" + decision.reason + ")" : "") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1246
- process.exit(2);
1247
- }
1248
1180
  }
1181
+ // KJC-TSK-1089 (ADR 0015): git push is no longer read here. The base branch is
1182
+ // protected (required checks), the card and the tests are judged at commit
1183
+ // (kj review), and pending card moves keep the turn from ending (stop gate).
1249
1184
  }
1250
1185
  } catch { /* fail open */ }
1251
1186
  process.exit(0);
@@ -173,7 +173,7 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
173
173
  if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
174
174
  }
175
175
  for (const s of sources) {
176
- const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(s) });
176
+ const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind() });
177
177
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
178
178
  }
179
179
  }
@@ -211,7 +211,7 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
211
211
  logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
212
212
  } else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
213
213
  // KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
214
- const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
214
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
215
215
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
216
216
  }
217
217
  }
@@ -55,12 +55,17 @@ export async function runOneShotReview({
55
55
  createAgentFn = createAgent,
56
56
  detectAgents = detectAvailableAgents,
57
57
  candidateStatusFn = candidateStatus,
58
+ // KJC-TSK-1075: a third-party PR is not the host's own work, so the reviewer
59
+ // it names runs even if it is the host; and nothing is stored, so the
60
+ // review can never unlock a commit of one's own.
61
+ reviewerOverride = null,
62
+ persist = true,
58
63
  }) {
59
64
  if (!diff || !diff.trim()) {
60
65
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
61
66
  }
62
67
 
63
- const reviewer = await pickCrossReviewer({ config, hostAgent, detectAgents });
68
+ const reviewer = reviewerOverride || await pickCrossReviewer({ config, hostAgent, detectAgents });
64
69
  if (!reviewer) {
65
70
  throw new Error(
66
71
  `cross-AI review requires an agent other than the host (${hostAgent || "unknown"}) — install codex, claude or another supported CLI`
@@ -123,7 +128,7 @@ export async function runOneShotReview({
123
128
  throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
124
129
  }
125
130
 
126
- return saveVerdict(projectDir, diff, {
131
+ const record = {
127
132
  verdict: parsed.approved ? "approved" : "rejected",
128
133
  reviewer: activeReviewer,
129
134
  host: hostAgent || null,
@@ -140,5 +145,6 @@ export async function runOneShotReview({
140
145
  // the verdict, bound to this diff, like the other two proofs.
141
146
  ...(ui ? { ui } : {}),
142
147
  confidence: parsed.confidence ?? null,
143
- });
148
+ };
149
+ return persist ? saveVerdict(projectDir, diff, record) : record;
144
150
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * KJC-TSK-1076: the requirements of a third-party PR, checked with no tokens.
3
+ * Each check is {id, ok, detail}; none stops the review (the agent decides).
4
+ * The rules are the own-work ones: the same budget, Conventional Commits and
5
+ * attribution patterns, and tests-with-code.
6
+ */
7
+ import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
8
+ import { loadPolicy } from "../policy/engine.js";
9
+ import { runCommand } from "../utils/process.js";
10
+ import { budgetedAdded } from "./loc-budget.js";
11
+ import { checkTestsWithCode } from "./tests-with-code.js";
12
+
13
+ const HEADER_MAX = 100;
14
+ const CONVENTIONAL = new RegExp(String.raw`^(${CONVENTIONAL_TYPES})(\([^)]+\))?!?: \S`);
15
+ const ATTRIBUTION = new RegExp(AI_ATTRIBUTION, "i");
16
+
17
+ const rowsOf = (numstat) => numstat.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
18
+ const [a, r, ...f] = l.split(/\s+/);
19
+ return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 };
20
+ });
21
+
22
+ /**
23
+ * The PR size budget, one source for every gate (PL-C, KJC-TSK-0735): the
24
+ * policy's net_lines_added invariant, else method_gates.pr_size_max, 200.
25
+ */
26
+ export function prSizeLimit({ projectDir, config }) {
27
+ const pol = loadPolicy({ projectDir });
28
+ const inv = pol.errors.length === 0
29
+ ? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
30
+ : null;
31
+ return {
32
+ ideal: config?.method_gates?.pr_size_warn ?? 150,
33
+ limit: inv?.max ?? config?.method_gates?.pr_size_max ?? 200,
34
+ source: inv ? `policy [${inv.id}]` : "method_gates",
35
+ };
36
+ }
37
+
38
+ /** Lines added that count against the budget (generated output and human docs exempt). */
39
+ export function sizeCheck({ numstat, limit }) {
40
+ const { added, exempt } = budgetedAdded(numstat);
41
+ const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
42
+ return added > limit
43
+ ? { id: "size", ok: false, detail: `${added} lines added${exemptNote}, over the ${limit}-line limit: partition the PR` }
44
+ : { id: "size", ok: true, detail: `${added} lines added${exemptNote}, within the ${limit}-line limit` };
45
+ }
46
+
47
+ /** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
48
+ export function commitChecks(commits) {
49
+ const badHeader = commits.filter((c) => !CONVENTIONAL.test(c.subject) || c.subject.length > HEADER_MAX);
50
+ const attributed = commits.filter((c) => ATTRIBUTION.test(`${c.subject}\n${c.body}`));
51
+ const list = (cs) => cs.map((c) => `${c.sha.slice(0, 8)} "${c.subject}"`).join(", ");
52
+ return [
53
+ badHeader.length
54
+ ? { id: "conventional-commits", ok: false, detail: `not Conventional Commits or over ${HEADER_MAX} characters: ${list(badHeader)}` }
55
+ : { id: "conventional-commits", ok: true, detail: `${commits.length} commit(s) with Conventional Commits headers` },
56
+ attributed.length
57
+ ? { id: "ai-attribution", ok: false, detail: `AI attribution in: ${list(attributed)}` }
58
+ : { id: "ai-attribution", ok: true, detail: "no AI attribution in the commits" },
59
+ ];
60
+ }
61
+
62
+ /** Source changed with no test changed (a delete-only change needs none). */
63
+ export function testsCheck({ config, numstat }) {
64
+ const rows = rowsOf(numstat);
65
+ const res = checkTestsWithCode({ config, stagedFiles: rows.map((r) => r.file), numstat: rows });
66
+ const ok = res.ok && res.mode !== "warn";
67
+ return { id: "tests", ok, detail: ok ? `tests: ${res.mode}` : res.reason || "source changed with no test changed" };
68
+ }
69
+
70
+ const git = async (projectDir, args) => {
71
+ const res = await runCommand("git", args, { cwd: projectDir });
72
+ if (res.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${(res.stderr || "").trim()}`);
73
+ return res.stdout;
74
+ };
75
+
76
+ /** Size, commits and tests of base...head, read from git. */
77
+ export async function prRequirementChecks({ range, projectDir, config, run = git }) {
78
+ const numstat = await run(projectDir, ["diff", "--numstat", range]);
79
+ // The commits of a PR are base..head, whichever range notation was given.
80
+ const [base, head] = range.split(/\.{2,3}/);
81
+ const log = await run(projectDir, ["log", "--format=%H%x1f%s%x1f%b%x1e", `${base}..${head || "HEAD"}`]);
82
+ const commits = log.split("\x1e").map((r) => r.trim()).filter(Boolean).map((r) => {
83
+ const [sha, subject = "", body = ""] = r.split("\x1f");
84
+ return { sha, subject, body };
85
+ });
86
+ return [sizeCheck({ numstat, limit: prSizeLimit({ projectDir, config }).limit }), ...commitChecks(commits), testsCheck({ config, numstat })];
87
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * KJC-TSK-1077: the security and privacy requirements of a third-party PR,
3
+ * with no tokens. Each check is {id, ok, detail} and never stops the review.
4
+ * They reuse the scanners kj applies to its own work: the output guard's
5
+ * credential patterns, the privacy scan, the prompt-injection catalog, the
6
+ * supply-chain cooldown and semgrep, limited to the PR's files.
7
+ */
8
+ import { CREDENTIAL_PATTERNS, scanDiff as scanGuards } from "../guards/output-guard.js";
9
+ import { scanDiff as scanInjection } from "../utils/injection-guard.js";
10
+ import { loadPrivacyList, scanText } from "../privacy/scan.js";
11
+ import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
12
+ import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
13
+ import { cooldownAdvisories } from "./dep-cooldown.js";
14
+
15
+ const CREDENTIAL_IDS = new Set(CREDENTIAL_PATTERNS.map((p) => p.id));
16
+ const BLOCKING_SEMGREP = new Set(["ERROR", "WARNING"]);
17
+ const check = (id, problems, okDetail) => (problems.length
18
+ ? { id, ok: false, detail: problems.join("; ") }
19
+ : { id, ok: true, detail: okDetail });
20
+
21
+ /** Credentials in added lines (AWS, private keys, tokens, URLs with passwords...). */
22
+ export function secretsCheck(diff, config) {
23
+ const found = scanGuards(diff, config).violations.filter((v) => CREDENTIAL_IDS.has(v.id));
24
+ return check("secrets", found.map((v) => `${v.file}:${v.line} ${v.id}`), "no secrets in the added lines");
25
+ }
26
+
27
+ /** Personal data in added lines, masked; generated output only for denylist hits. */
28
+ export function privacyCheck(diff, { list = loadPrivacyList() } = {}) {
29
+ const found = splitAddedByFile(diff).flatMap(({ file, added }) => {
30
+ const hits = scanText(added, { list, source: file });
31
+ return isGeneratedPath(file) ? hits.filter((f) => f.severity === "block") : hits;
32
+ });
33
+ return check("privacy", found.map((f) => `${f.source}:${f.line} ${f.type} ${f.masked}`), "no personal data in the added lines");
34
+ }
35
+
36
+ /** Prompt-injection patterns in the added lines (text an AI will read). */
37
+ export function injectionCheck(diff) {
38
+ const res = scanInjection(diff);
39
+ return check("prompt-injection", res.clean ? [] : [res.summary], "no prompt-injection patterns");
40
+ }
41
+
42
+ /** Dependencies the lockfile adds that were published under the threshold. */
43
+ export async function cooldownCheck(diff, { advisories = cooldownAdvisories, thresholdHours } = {}) {
44
+ const { young = [], unverified = [], skipped = 0 } = await advisories(diff, thresholdHours ? { thresholdHours } : {});
45
+ const problems = young.map((p) => `${p.name}@${p.version} published ${p.hours} h ago`);
46
+ const notes = [...unverified.map((p) => `${p.name}@${p.version} unverified`), ...(skipped ? [`${skipped} packages changed, none checked`] : [])];
47
+ const res = check("dependency-cooldown", problems, "no dependency published in the last hours");
48
+ return notes.length ? { ...res, detail: `${res.detail} (${notes.join("; ")})` } : res;
49
+ }
50
+
51
+ /** semgrep (the static part of kj audit --security), counted on the PR's files only. */
52
+ export async function semgrepCheck({ projectDir, files, collect = collectSemgrepFindings }) {
53
+ const res = await collect(projectDir);
54
+ if (!res.available) return { id: "semgrep", ok: true, detail: `not available: ${res.reason}` };
55
+ const inPr = new Set(files);
56
+ const found = (res.findings ?? []).filter((f) => inPr.has(f.file) && BLOCKING_SEMGREP.has(f.severity));
57
+ return check("semgrep", found.map((f) => `${f.file}:${f.line} ${f.rule}`), "no semgrep findings in the PR's files");
58
+ }
59
+
60
+ /** Every security and privacy check of the PR's diff. */
61
+ export async function prSecurityChecks({ diff, projectDir, config }) {
62
+ const files = splitAddedByFile(diff).map((f) => f.file);
63
+ return [
64
+ secretsCheck(diff, config),
65
+ privacyCheck(diff),
66
+ injectionCheck(diff),
67
+ await cooldownCheck(diff, { thresholdHours: config?.supply_chain?.cooldown_hours }),
68
+ await semgrepCheck({ projectDir, files }),
69
+ ];
70
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * KJC-TSK-1078: Sonar on a third-party PR, when SonarQube is reachable. The
3
+ * own-work pre-gate scans the PR's files and only issues on the lines the PR
4
+ * adds can fail the check; without Sonar the check says "not available" and
5
+ * passes (decision of the user: Sonar if available, never demanded).
6
+ */
7
+ import { addedLinesByFile, issueFile, runSonarPregate } from "./sonar-pregate.js";
8
+
9
+ export async function sonarCheck({ diff, config, projectDir = process.cwd(), pregate = runSonarPregate }) {
10
+ const touchedLines = addedLinesByFile(diff);
11
+ let res;
12
+ try {
13
+ res = await pregate({ config, stagedFiles: [...touchedLines.keys()], touchedLines, projectDir });
14
+ } catch (err) {
15
+ res = { available: false, reason: err.message };
16
+ }
17
+ if (!res.available) return { id: "sonar", ok: true, detail: `not available: ${res.reason}` };
18
+ const blocking = res.blocking ?? [];
19
+ return blocking.length
20
+ ? { id: "sonar", ok: false, detail: blocking.map((i) => `${issueFile(i)}:${i.line} ${i.rule} ${i.severity}`).join("; ") }
21
+ : { id: "sonar", ok: true, detail: `no blocking Sonar issue on the added lines (${(res.advisory ?? []).length} advisory)` };
22
+ }
@@ -82,7 +82,8 @@ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSy
82
82
  return groups;
83
83
  }
84
84
 
85
- export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync }) {
85
+ // KJC-TSK-1078: projectDir is where the files live (a third-party PR's clone may not be the cwd).
86
+ export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync, projectDir = process.cwd() }) {
86
87
  if (config?.review_gate?.sonar === false) {
87
88
  return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
88
89
  }
@@ -94,7 +95,9 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
94
95
  let lock = null;
95
96
  try {
96
97
  lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000, slots });
97
- const groups = groupByScanRoot(stagedFiles, { exists });
98
+ const groups = groupByScanRoot(stagedFiles, { root: projectDir, exists });
99
+ // The root keeps the scanner's own default (cwd) unless the project lives elsewhere.
100
+ const rootCwd = projectDir === process.cwd() ? null : projectDir;
98
101
  if (groups.size === 0) groups.set("", []);
99
102
  const indexed = new Set();
100
103
  const issues = [];
@@ -103,7 +106,8 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
103
106
  for (const dir of groups.keys()) {
104
107
  const prefix = dir ? `${dir}/` : "";
105
108
  // KJC-TSK-0838: verbose, so the scanner names every file it indexed.
106
- const scan = await runSonarScan(config, null, { verbose: true, ...(dir ? { cwd: join(process.cwd(), dir) } : {}) });
109
+ const scanCwd = dir ? join(projectDir, dir) : rootCwd;
110
+ const scan = await runSonarScan(config, null, { verbose: true, ...(scanCwd ? { cwd: scanCwd } : {}) });
107
111
  if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
108
112
  if (!scan.ok) {
109
113
  return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * KJC-TSK-1075 (#2227): review someone else's PR. kj's reviewer, the target
3
+ * repo's rules (.karajan) and the provider the agent picks run over
4
+ * base...head; no own-work gate applies (card, tests, policy, session RAG) and
5
+ * no verdict is stored, so this mode never unlocks a commit (ADR 0015). The
6
+ * own-work gate, reviewGateCommand, stays untouched.
7
+ */
8
+ import { runCommand } from "../utils/process.js";
9
+ import { resolveRole } from "../config/role-resolver.js";
10
+ import { runOneShotReview } from "./one-shot-review.js";
11
+ import { prRequirementChecks } from "./pr-requirements.js";
12
+ import { prSecurityChecks } from "./pr-security.js";
13
+ import { sonarCheck } from "./pr-sonar.js";
14
+
15
+ const gitDiffOf = async (projectDir, range) => {
16
+ const res = await runCommand("git", ["diff", range], { cwd: projectDir });
17
+ if (res.exitCode !== 0) throw new Error(`git diff ${range} failed: ${(res.stderr || "").trim()}`);
18
+ return res.stdout;
19
+ };
20
+
21
+ const finding = (i) => ({
22
+ file: i.file ?? null, line: i.line ?? null, severity: i.severity ?? null,
23
+ description: i.description ?? "", suggested_fix: i.suggested_fix ?? null,
24
+ });
25
+
26
+ /** The review as data: verdict, findings, suggestions and the PR-requirement checks. */
27
+ export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck }) {
28
+ const diff = await gitDiff(projectDir, range);
29
+ // KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
30
+ const checks = [...await requirements({ range, projectDir, config }), ...await security({ diff, projectDir, config }), await sonar({ diff, config, projectDir })];
31
+ const record = await run({
32
+ diff, task, config, logger, projectDir,
33
+ reviewerOverride: resolveRole(config, "reviewer").provider || null,
34
+ persist: false,
35
+ });
36
+ return {
37
+ mode: "third-party",
38
+ range,
39
+ reviewer: record.reviewer,
40
+ verdict: { approved: record.verdict === "approved", summary: record.summary ?? "" },
41
+ findings: (record.issues ?? []).map(finding),
42
+ suggestions: record.suggestions ?? [],
43
+ checks,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The CLI side: stdout carries exactly one JSON document, logs go to stderr.
49
+ * Exit 0 whatever the PR's verdict (the agent decides what to post); non-zero
50
+ * only when kj itself fails.
51
+ */
52
+ export async function thirdPartyReviewCommand({ flags, config, out = process.stdout, err = process.stderr, review = thirdPartyReview }) {
53
+ const print = (doc) => out.write(`${JSON.stringify(doc)}\n`);
54
+ if (!flags.range) {
55
+ print({ error: "--third-party reviews a range: pass --range <base>...<head>" });
56
+ return 1;
57
+ }
58
+ const log = (msg) => err.write(`${msg}\n`);
59
+ const logger = { info: log, warn: log, error: log, debug: () => {} };
60
+ try {
61
+ print(await review({ range: flags.range, config, projectDir: config.projectDir || process.cwd(), logger, task: flags.task ?? null }));
62
+ return 0;
63
+ } catch (e) {
64
+ print({ error: e.message });
65
+ return 1;
66
+ }
67
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * KJC-TSK-1086: the Steward's block (STW-C, KJC-TSK-0791) at commit too. Only
3
+ * where the project adopted it (method_gates.steward: block), only the hard
4
+ * invariants (vulnerable dependencies, a persistently red main), and never on
5
+ * an invariant whose verdict is unknown. Having declared block, a missing or
6
+ * unreadable report blocks too: the sweep has to run.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const HARD = new Set(["vulnerable-deps", "main-ci"]);
12
+
13
+ /** @returns {{ sweptAt: string, hard: object[], reason: string } | null} */
14
+ export function stewardBlocking({ projectDir, config }) {
15
+ if (config?.method_gates?.steward !== "block") return null;
16
+ const file = join(projectDir, ".karajan", "steward", "report.json");
17
+ let rep;
18
+ try {
19
+ rep = JSON.parse(readFileSync(file, "utf8"));
20
+ } catch (err) {
21
+ // block was adopted explicitly: a missing or unreadable report is never a silent pass.
22
+ return { sweptAt: null, hard: [], reason: `steward cannot read ${file} (${err.message}) — rerun kj steward sweep; while it is unreadable the block stands` };
23
+ }
24
+ if (!Array.isArray(rep?.invariants)) {
25
+ return { sweptAt: rep?.sweptAt ?? null, hard: [], reason: `steward report ${file} has no invariants list — rerun kj steward sweep; while it is malformed the block stands` };
26
+ }
27
+ const hard = rep.invariants.filter((r) => r?.verdict === "broken" && HARD.has(r.id));
28
+ if (hard.length === 0) return null;
29
+ const lines = hard.map((r) => `${r.id}: ${r.evidence || ""} -> ${r.remedy || r.renew || "kj steward sweep"}`);
30
+ return { sweptAt: rep.sweptAt, hard, reason: `steward (sweep ${rep.sweptAt}) blocks: ${lines.join("; ")} — if the work goes on, your user sets method_gates.steward to inform` };
31
+ }