karajan-code 4.56.0 → 4.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.56.0",
3
+ "version": "4.57.1",
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",
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * osv-gate (KJC-TSK-1097): CI fails on any advisory osv-scanner scores 9 or more
4
+ * (critical), naming the lockfile, package, version and advisory. Output it cannot
5
+ * read fails too: a scanner that broke is not a clean scan.
6
+ *
7
+ * Usage: osv-scanner --format=json -r . > osv.json; node scripts/osv-gate.mjs osv.json
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { relative } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { parseOsvOutput } from "../src/audit/osv-findings.js";
13
+
14
+ const CRITICAL = 9;
15
+
16
+ /** One line per critical package and advisory, paths relative to the repo root. */
17
+ export function criticalAdvisories(raw, root) {
18
+ const lines = parseOsvOutput(raw).vulnerabilities
19
+ .filter((v) => v.score !== null && v.score >= CRITICAL)
20
+ .map((v) => `${relative(root, v.source)}: ${v.package}@${v.version} ${v.id} (${v.score})`);
21
+ return [...new Set(lines)];
22
+ }
23
+
24
+ /** The gate's verdict for the scanner's raw output. */
25
+ export function gateReport(text, root) {
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(text);
29
+ } catch {
30
+ raw = null;
31
+ }
32
+ if (!Array.isArray(raw?.results)) return { ok: false, message: "osv-gate: could not read the osv-scanner output (no results list)" };
33
+ const found = criticalAdvisories(raw, root);
34
+ if (found.length === 0) return { ok: true, message: "osv-gate: no critical advisories" };
35
+ const details = found.map((line) => "- " + line).join("\n");
36
+ return { ok: false, message: `osv-gate: ${found.length} critical advisory(ies):\n${details}` };
37
+ }
38
+
39
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
40
+ const file = process.argv[2];
41
+ if (!file) throw new Error("osv-gate: pass the osv-scanner JSON file");
42
+ const { ok, message } = gateReport(readFileSync(file, "utf8"), process.cwd());
43
+ console.log(message);
44
+ process.exitCode = ok ? 0 : 1;
45
+ }
@@ -103,6 +103,7 @@ export function parseOsvOutput(raw) {
103
103
  id: vuln.id || vuln.aliases?.[0] || "UNKNOWN",
104
104
  aliases: vuln.aliases || [],
105
105
  severity: extractSeverity(vuln, groups),
106
+ score: groupScore(vuln, groups),
106
107
  summary: vuln.summary || vuln.details?.split("\n")[0] || "",
107
108
  publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
108
109
  package: name,
@@ -141,6 +142,13 @@ function extractSeverity(vuln, groups) {
141
142
  return "UNKNOWN";
142
143
  }
143
144
 
145
+ /** KJC-TSK-1097: the numeric CVSS score of the vuln's group (9 or more is critical), or null. */
146
+ function groupScore(vuln, groups) {
147
+ const group = groups.find((g) => g.ids?.includes(vuln.id) && g.max_severity);
148
+ const score = group ? Number.parseFloat(group.max_severity) : Number.NaN;
149
+ return Number.isNaN(score) ? null : score;
150
+ }
151
+
144
152
  function extractFixedVersion(vuln) {
145
153
  const ranges = vuln.affected?.[0]?.ranges || [];
146
154
  for (const range of ranges) {
@@ -70,7 +70,13 @@ async function policyRangeCheck(projectDir) {
70
70
  const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
71
71
  if (hard.length > 0) {
72
72
  const list = hard.map((v) => `[${v.rule_id}]${v.file ? ` ${v.file}` : ""}`).join(", ");
73
- return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}` };
73
+ // KJC-BUG-0374: supervisor files the seal does not back (an older kj's seal, or an
74
+ // unsealed edit) are fixed by regenerating and resealing, never by a tag or a grant.
75
+ const why = sup.note ? ` (${sup.note})` : "";
76
+ const reseal = hard.some((v) => v.rule_id === "defaults.supervisor.write")
77
+ ? ` — the supervisor seal does not back these files${why}: run kj harden, then ask your user for kj harden --commit`
78
+ : "";
79
+ return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}${reseal}` };
74
80
  }
75
81
  return { name: "policy", ok: true, detail: `${scope} clean against the current policy (${violations.length} warning(s))${skipped}` };
76
82
  } catch (err) {
@@ -1048,6 +1048,7 @@ export function registerMeta(program, { pkgVersion }) {
1048
1048
  .option("--no-config", "Skip lint/format/commit config files (hooks only)")
1049
1049
  .option("--no-ci", "Skip CI quality workflows")
1050
1050
  .option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
1051
+ .option("--no-sandbox", "Leave the host Bash sandbox alone (by default it is turned on where it can start)")
1051
1052
  .option("--mutation", "Also seed an opt-in nightly/manual mutation CI job (never a PR gate)")
1052
1053
  .option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
1053
1054
  .option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
@@ -1065,6 +1066,7 @@ export function registerMeta(program, { pkgVersion }) {
1065
1066
  config: flags.config !== false,
1066
1067
  ci: flags.ci !== false,
1067
1068
  guidelines: flags.guidelines !== false,
1069
+ sandbox: flags.sandbox !== false,
1068
1070
  mutation: Boolean(flags.mutation),
1069
1071
  only: flags.only ?? [],
1070
1072
  exclude: flags.exclude ?? [],
@@ -23,6 +23,7 @@ import { commandsForLanguage } from "../harden/hook-commands.js";
23
23
  import { installHooks } from "../harden/harden-engine.js";
24
24
  import { installHarnessHooks } from "../harden/harness-hooks.js";
25
25
  import { installSentinelHooks } from "../harden/sentinel-hooks.js";
26
+ import { installSandbox } from "../harden/sandbox.js";
26
27
  import { detectStackRoots } from "../harden/stack-roots.js";
27
28
  import { installWorkflows } from "../harden/workflow-engine.js";
28
29
  import { adviseBranchProtection } from "../harden/branch-protection.js";
@@ -91,6 +92,7 @@ export async function hardenCommand({
91
92
  only = [],
92
93
  exclude = [],
93
94
  commitSupervisor = false,
95
+ sandbox = true,
94
96
  kjVersion = null,
95
97
  // KJC-BUG-0289: the contract files already dirty before anything was generated
96
98
  // (kj init captures them before writing its own). What is dirty NOW is the person's.
@@ -160,6 +162,8 @@ export async function hardenCommand({
160
162
  // sin sello, que es como una sesión se dejaba sin herramientas.
161
163
  const sh = installSentinelHooks({ projectDir, logger, human: commitSupervisor });
162
164
  result.sentinelHooks = sh.wired ? "wired" : "script-only";
165
+ // KJC-TSK-1092: the host sandbox, on by default wherever it can start here.
166
+ if (sandbox) result.sandbox = installSandbox({ projectDir, logger }).state;
163
167
  }
164
168
  } catch (err) {
165
169
  if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
@@ -34,7 +34,8 @@ export async function worktreeCommand({ config = null, action, args = [], flags
34
34
  throw new Error(`lane "${slug}" already exists — cd ${wtPath}, or finish it with: kj worktree done ${slug}`);
35
35
  }
36
36
  const branch = `feat/${slug}`;
37
- await git(["worktree", "add", "-b", branch, wtPath]);
37
+ // KJC-TSK-1093: --no-track, so no upstream lands in .git/config (read-only in the host sandbox).
38
+ await git(["worktree", "add", "--no-track", "-b", branch, wtPath]);
38
39
  const boot = await bootstrapWorktree({ worktreePath: wtPath, setupCommand: config?.session?.worktree_setup || null });
39
40
  console.log(`✓ lane ready: ${branch}`);
40
41
  console.log(` cd ${wtPath}`);
@@ -150,9 +150,9 @@ export async function prepareHuBranch({ story, huBranches, config, logger, cardI
150
150
  */
151
151
  export function pushRemedy(message, branch) {
152
152
  if (/workflow/i.test(message) && /scope/i.test(message)) {
153
- return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push -u origin ${branch}`;
153
+ return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push origin ${branch}`;
154
154
  }
155
- return `push the branch yourself: git push -u origin ${branch}`;
155
+ return `push the branch yourself: git push origin ${branch}`;
156
156
  }
157
157
 
158
158
  /** The warnings a run reports when the push or the PR of any HU failed. */
@@ -48,10 +48,13 @@ export function configLeftAlone(cfg, altDirs) {
48
48
  const variant = findEquivalentIn(altDirs, id);
49
49
  if (variant) return { action: "covered", by: variant };
50
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;
51
+ return missingToolGap(cfg, altDirs);
52
+ }
53
+
54
+ /** KJC-BUG-0376: the first tool the config needs and the project lacks, as a report entry. */
55
+ function missingToolGap(cfg, dirs) {
56
+ const missing = [cfg.requires ?? []].flat().find((tool) => !hasTool(dirs, tool));
57
+ return missing ? { action: "omitted", note: `${missing} is not installed — npm i -D ${missing}, then re-run kj harden` } : null;
55
58
  }
56
59
 
57
60
  function writeFile(target, content) {
@@ -89,6 +92,12 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
89
92
  results.push({ file, action: "skipped" });
90
93
  continue;
91
94
  }
95
+ // KJC-BUG-0376: never rewrite a managed block into one the project cannot load.
96
+ const gap = exists ? missingToolGap(cfg, altDirs) : null;
97
+ if (gap) {
98
+ results.push({ file, ...gap });
99
+ continue;
100
+ }
92
101
  const { content, action } = upsertManagedBlock({
93
102
  source,
94
103
  blockId: cfg.blockId,
@@ -72,6 +72,19 @@ export const ESLINT_BODY = [
72
72
  "];",
73
73
  ].join("\n");
74
74
 
75
+ // KJC-BUG-0376: a TypeScript root gets the same rules with the TypeScript parser;
76
+ // without it ESLint 9 fails every .ts/.tsx file with "Parsing error".
77
+ const JS_LANGUAGE_OPTIONS = 'languageOptions: { ecmaVersion: 2025, sourceType: "module" }';
78
+ if (!ESLINT_BODY.includes(JS_LANGUAGE_OPTIONS)) throw new Error("config-templates: ESLINT_BODY lost its languageOptions line");
79
+ export const ESLINT_TS_BODY = [
80
+ 'import tsParser from "@typescript-eslint/parser";',
81
+ "",
82
+ ESLINT_BODY.replace(
83
+ JS_LANGUAGE_OPTIONS,
84
+ 'languageOptions: { parser: tsParser, ecmaVersion: 2025, sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } }',
85
+ ),
86
+ ].join("\n");
87
+
75
88
  // Ruff covers lint + format for Python; `UP` (pyupgrade) is the analogue of
76
89
  // the ES2025 deprecated-API ban — it rewrites legacy idioms to modern Python.
77
90
  export const RUFF_BODY = [
@@ -114,6 +127,9 @@ export const JS_CONFIGS = [
114
127
  { file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
115
128
  { file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
116
129
  ];
130
+ export const TS_CONFIGS = JS_CONFIGS.map((cfg) => (cfg.file === "eslint.config.js"
131
+ ? { ...cfg, body: ESLINT_TS_BODY, requires: ["eslint", "@typescript-eslint/parser"] }
132
+ : cfg));
117
133
  export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
118
134
  export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
119
135
 
@@ -135,7 +151,7 @@ export const GENERATED_CONFIG_FILES = new Set(
135
151
  /** Lint/format configs by detected language. Unknown ⇒ universal only. */
136
152
  export const CONFIGS_BY_LANGUAGE = {
137
153
  javascript: JS_CONFIGS,
138
- typescript: JS_CONFIGS,
154
+ typescript: TS_CONFIGS,
139
155
  python: PY_CONFIGS,
140
156
  go: GO_CONFIGS,
141
157
  php: PHP_CONFIGS,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * sandbox — kj harden turns the host's Bash sandbox on (KJC-TSK-1087, ADR 0015).
3
+ *
4
+ * Claude Code sandboxes Bash with bubblewrap on Linux and seatbelt on macOS. With
5
+ * allowUnsandboxedCommands off, a sandbox that cannot start leaves the session with
6
+ * no Bash at all (pilot KJC-TSK-1083: Ubuntu 24.04 restricts user namespaces until
7
+ * bwrap has an AppArmor profile). So the probe runs first, on this machine.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { delimiter, dirname, join } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ const USERNS_SYSCTL = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
14
+
15
+ const whichOnPath = (bin) => (process.env.PATH ?? "").split(delimiter).map((d) => join(d, bin)).find((p) => existsSync(p)) ?? null;
16
+ const runSync = (bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 10_000 });
17
+
18
+ const missing = (tool, pkg) => ({ ok: false, reason: `${tool} is not installed`, remedy: `install ${pkg} (apt install ${pkg}, dnf install ${pkg})` });
19
+
20
+ function userNamespacesRestricted(readFile) {
21
+ try { return readFile(USERNS_SYSCTL).trim() === "1"; } catch { return false; }
22
+ }
23
+
24
+ function probeLinux({ which, run, readFile }) {
25
+ const bwrap = which("bwrap");
26
+ if (!bwrap) return missing("bubblewrap (bwrap)", "bubblewrap");
27
+ if (!which("socat")) return missing("socat", "socat");
28
+ // The same namespaces Claude Code's sandbox asks for: user and network.
29
+ const res = run(bwrap, ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-user", "--unshare-net", "true"]);
30
+ if (res.status === 0) return { ok: true };
31
+ if (userNamespacesRestricted(readFile)) {
32
+ return {
33
+ ok: false,
34
+ reason: "AppArmor restricts unprivileged user namespaces and bwrap has no profile",
35
+ remedy: "add /etc/apparmor.d/bwrap allowing userns for /usr/bin/bwrap, then load it with apparmor_parser -r (your user does this)",
36
+ };
37
+ }
38
+ const detail = String(res.stderr ?? "").trim() || "exit " + res.status;
39
+ return { ok: false, reason: `bwrap cannot create its namespaces: ${detail}`, remedy: "fix bwrap on this machine, then run kj harden again" };
40
+ }
41
+
42
+ /**
43
+ * Can the host sandbox start on this machine? Never throws.
44
+ * @returns {{ok: true} | {ok: false, reason: string, remedy?: string}}
45
+ */
46
+ export function probeSandbox({ platform = process.platform, which = whichOnPath, run = runSync, readFile = (p) => readFileSync(p, "utf8"), exists = existsSync } = {}) {
47
+ if (platform === "linux") return probeLinux({ which, run, readFile });
48
+ if (platform === "darwin") {
49
+ if (!exists("/usr/bin/sandbox-exec")) return { ok: false, reason: "sandbox-exec is missing from /usr/bin" };
50
+ const res = run("/usr/bin/sandbox-exec", ["-p", "(version 1)(allow default)", "/usr/bin/true"]);
51
+ const detail = String(res.stderr ?? "").trim() || "exit " + res.status;
52
+ return res.status === 0 ? { ok: true } : { ok: false, reason: `sandbox-exec cannot start a sandbox: ${detail}` };
53
+ }
54
+ return { ok: false, reason: `the host sandbox does not run on ${platform}` };
55
+ }
56
+
57
+ // KJC-TSK-1092: what the method needs from the sandbox (lessons of the pilot, KJC-TSK-1083).
58
+ // gh keeps its tokens in the keyring over D-Bus; kj review reaches a local SonarQube,
59
+ // which the sandbox's private localhost cannot (the reviewer agents run inside it).
60
+ // Outside the project only kj's state and package caches are writable, and the
61
+ // supervisor and the phone key stay out of reach.
62
+ const METHOD_SANDBOX = {
63
+ excludedCommands: ["gh *", "kj review *"],
64
+ denyWrite: [".karajan/harness", ".karajan/supervisor-provenance.json", "~/.karajan/supervisor-phone.json"],
65
+ allowWrite: ["~/.karajan", "~/.npm/_cacache", "~/.npm/_logs", "~/.npm/_npx", "~/.cache", "~/.config/configstore"],
66
+ allowedDomains: [
67
+ "github.com", "api.github.com", "*.githubusercontent.com", "uploads.github.com", "registry.npmjs.org",
68
+ "api.openai.com", "chatgpt.com", "*.openai.com", "api.anthropic.com", "*.anthropic.com", "claude.ai",
69
+ "api.githubcopilot.com", "*.githubcopilot.com", "*.googleapis.com",
70
+ ],
71
+ };
72
+
73
+ const union = (have, add) => [...new Set([...(Array.isArray(have) ? have : []), ...add])];
74
+
75
+ function withMethodSandbox(settings) {
76
+ const sb = settings.sandbox ?? {};
77
+ const fsCfg = sb.filesystem ?? {};
78
+ const net = sb.network ?? {};
79
+ return {
80
+ ...settings,
81
+ sandbox: {
82
+ ...sb,
83
+ enabled: true,
84
+ allowUnsandboxedCommands: false,
85
+ excludedCommands: union(sb.excludedCommands, METHOD_SANDBOX.excludedCommands),
86
+ filesystem: { ...fsCfg, denyWrite: union(fsCfg.denyWrite, METHOD_SANDBOX.denyWrite), allowWrite: union(fsCfg.allowWrite, METHOD_SANDBOX.allowWrite) },
87
+ network: { ...net, allowedDomains: union(net.allowedDomains, METHOD_SANDBOX.allowedDomains) },
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Turn the host sandbox on in .claude/settings.local.json when it can start here.
94
+ * @returns {{state: "on" | "off" | "left-off" | "untouched", reason?: string}}
95
+ */
96
+ export function installSandbox({ projectDir, logger = console, probe = probeSandbox }) {
97
+ const ready = probe();
98
+ if (!ready.ok) {
99
+ const remedy = ready.remedy ? `; ${ready.remedy}` : "";
100
+ logger.info?.(`kj harden: host sandbox left off, ${ready.reason}${remedy}`);
101
+ return { state: "off", reason: ready.reason };
102
+ }
103
+ const path = join(projectDir, ".claude", "settings.local.json");
104
+ let settings = {};
105
+ if (existsSync(path)) {
106
+ try {
107
+ settings = JSON.parse(readFileSync(path, "utf8"));
108
+ } catch {
109
+ logger.warn?.(`kj harden: ${path} is not valid JSON, the host sandbox was not written`);
110
+ return { state: "untouched", reason: "invalid JSON" };
111
+ }
112
+ }
113
+ if (settings.sandbox?.enabled === false) {
114
+ logger.info?.(`kj harden: the host sandbox stays off, ${path} says enabled: false`);
115
+ return { state: "left-off", reason: "enabled: false" };
116
+ }
117
+ mkdirSync(dirname(path), { recursive: true });
118
+ writeFileSync(path, `${JSON.stringify(withMethodSandbox(settings), null, 2)}\n`);
119
+ return { state: "on" };
120
+ }
@@ -87,20 +87,21 @@ export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
87
87
  });
88
88
 
89
89
  // KJC-BUG-0204: the real command puts flags between the verb's words
90
- // (firebase --account a@b deploy), so they are read in order, not adjacent.
91
- const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
90
+ // (npm --prefix x publish), so they are read in order, not adjacent.
91
+ // KJC-BUG-0373: only a release of the package; a deploy (database rules, a site)
92
+ // is not one, and gating it kept an open database open.
93
+ const PUBLISH_VERBS = [["npm", "publish"], ["gh", "release", "create"]];
92
94
  // Known options that take a separate value: that value is not a subcommand word
93
95
  // (npm --tag publish test does not publish). An unknown option is not skipped, so
94
96
  // a real publish behind it is still read (a false positive costs less than a miss).
95
97
  const VALUE_OPTIONS = {
96
98
  npm: ["--prefix", "--tag", "--registry", "--userconfig", "--workspace", "-w", "--otp", "--access", "--cache", "--loglevel", "-C"],
97
- firebase: ["--account", "--project", "-P", "--config", "-c", "--only", "--except", "--token", "--message", "-m"],
98
99
  gh: ["--repo", "-R", "--hostname"],
99
100
  };
100
101
 
101
102
  /**
102
- * KJC-BUG-0333 (#2093): a simple command whose head is npm, firebase or gh and
103
- * whose own words then say publish/deploy/release create. Words inside a quoted
103
+ * KJC-BUG-0333 (#2093): a simple command whose head is npm or gh and whose own
104
+ * words then say publish/release create. Words inside a quoted
104
105
  * argument of another command (a kj rag query text, a commit message) are one
105
106
  * word of THAT command, so they never read as a publish.
106
107
  * @param {string} cmd
@@ -81,6 +81,11 @@ const SEMVER_OP = String.raw`(?:[\^~]|[<>]=?|=)`;
81
81
  const SEMVER_JOIN = String.raw`\s*(?:(?:\|\||-)\s*${SEMVER_OP}?|${SEMVER_OP})\s*`;
82
82
  const SEMVER_VALUE = new RegExp(String.raw`^\s*${SEMVER_OP}?\s*${SEMVER_TOKEN}(?:${SEMVER_JOIN}${SEMVER_TOKEN})*\s*$`);
83
83
 
84
+ /** KJC-BUG-0380: a pair is discarded only when it is a real latitude, longitude. */
85
+ function inGpsRange(_match, lat, lng) {
86
+ return Math.abs(Number(lat)) <= 90 && Math.abs(Number(lng)) <= 180;
87
+ }
88
+
84
89
  const CONTEXT_DISCARDS = [
85
90
  { type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
86
91
  { type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
@@ -114,7 +119,12 @@ const CONTEXT_DISCARDS = [
114
119
  // lookarounds instead of \b). Only that WHOLE shape with a real 20xx date and
115
120
  // time: no card of 14 digits starts with 20 (Diners is 30/36/38), and a
116
121
  // 16-digit card does not fit the slot.
117
- { type: "plan-id", re: /(?<![A-Za-z0-9])plan-20\d\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d-[a-z0-9]{4}(?![A-Za-z0-9])/g },
122
+ // KJC-BUG-0380 (#2263): a latitude, longitude pair in test data read as a phone.
123
+ // Only the WHOLE pair: both with a decimal point and four or more decimals, in
124
+ // range (latitude up to 90, longitude up to 180), joined by a comma. A lone
125
+ // number or a pair out of range still warns.
126
+ { type: "gps-pair", re: /(?<![\d.])(-?\d{1,3}\.\d{4,})\s*,\s*(-?\d{1,3}\.\d{4,})(?![\d.])/g, keep: inGpsRange },
127
+ { type: "plan-id", re:/(?<![A-Za-z0-9])plan-20\d\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d-[a-z0-9]{4}(?![A-Za-z0-9])/g },
118
128
  ];
119
129
 
120
130
  /**
@@ -160,9 +170,13 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
160
170
  re.lastIndex = m.index + m[0].length;
161
171
  }
162
172
  }
163
- for (const { re } of CONTEXT_DISCARDS) {
173
+ for (const { re, keep } of CONTEXT_DISCARDS) {
164
174
  re.lastIndex = 0;
165
- probe = probe.replace(re, (m) => { discarded += 1; return " ".repeat(m.length); });
175
+ probe = probe.replace(re, (m, ...groups) => {
176
+ if (keep && !keep(m, ...groups)) return m;
177
+ discarded += 1;
178
+ return " ".repeat(m.length);
179
+ });
166
180
  }
167
181
  const red = redactPII(probe);
168
182
  if (red.total > 0) {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * diff-batches (KJC-BUG-0378, #2263): a third-party PR can be far larger than
3
+ * what one reviewer prompt holds (the reviewer clips at DIFF_CLIP_CHARS). The
4
+ * diff is cut at each file and whole files are packed into batches that fit, so
5
+ * every batch is reviewed complete. A file larger than the limit, or files past
6
+ * the batch cap, are not reviewed and are named in the coverage, never hidden.
7
+ */
8
+
9
+ const FILE_HEAD = /^diff --git a\/(.+?) b\//;
10
+
11
+ /** The diff cut at each file, with the number of changed lines in each. */
12
+ export function splitByFile(diff) {
13
+ const parts = [];
14
+ for (const line of String(diff).split("\n")) {
15
+ const head = FILE_HEAD.exec(line);
16
+ if (head) parts.push({ file: head[1], lines: [] });
17
+ if (parts.length === 0) continue;
18
+ parts.at(-1).lines.push(line);
19
+ }
20
+ return parts.map(({ file, lines }) => ({
21
+ file,
22
+ text: lines.join("\n"),
23
+ changed: lines.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)).length,
24
+ }));
25
+ }
26
+
27
+ /**
28
+ * Whole files packed into batches of at most `limit` characters, at most
29
+ * `maxBatches` of them, and the coverage that leaves.
30
+ */
31
+ export function batchDiff(diff, { limit, maxBatches }) {
32
+ const batches = [];
33
+ const unreviewed = [];
34
+ for (const part of splitByFile(diff)) {
35
+ const last = batches.at(-1);
36
+ if (part.text.length > limit) unreviewed.push(part);
37
+ else if (last && last.text.length + 1 + part.text.length <= limit) last.add(part);
38
+ else if (batches.length < maxBatches) batches.push(newBatch(part));
39
+ else unreviewed.push(part);
40
+ }
41
+ const reviewed = batches.flatMap((b) => b.parts);
42
+ const sum = (ps) => ps.reduce((n, p) => n + p.changed, 0);
43
+ return {
44
+ batches: batches.map((b) => ({ files: b.parts.map((p) => p.file), text: b.text })),
45
+ coverage: {
46
+ files: { reviewed: reviewed.length, total: reviewed.length + unreviewed.length },
47
+ lines: { reviewed: sum(reviewed), total: sum(reviewed) + sum(unreviewed) },
48
+ truncated: unreviewed.length > 0,
49
+ unreviewed: unreviewed.map((p) => p.file),
50
+ },
51
+ };
52
+ }
53
+
54
+ function newBatch(first) {
55
+ const batch = {
56
+ parts: [first],
57
+ text: first.text,
58
+ add(part) {
59
+ batch.parts.push(part);
60
+ batch.text = `${batch.text}\n${part.text}`;
61
+ },
62
+ };
63
+ return batch;
64
+ }
@@ -8,7 +8,9 @@
8
8
  * No escape (ADR 0015): deletions and comment-only cleanups already pass.
9
9
  */
10
10
 
11
- const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
11
+ import { isGenerated } from "./loc-budget.js";
12
+
13
+ const DEFAULT_TEST_PATTERNS =["/tests/", "/__tests__/", ".test.", ".spec."];
12
14
  const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
13
15
 
14
16
  /**
@@ -22,8 +24,9 @@ export function sourceFilesOf(config = {}, files = []) {
22
24
  // Staged paths are repo-relative (no leading slash) — normalize so the
23
25
  // "/tests/" style patterns also match the top-level tests directory.
24
26
  const isTest = (f) => patterns.some((p) => `/${f}`.includes(p));
27
+ // KJC-BUG-0377: build output (a rebuilt docs site's _astro/*.js) is nobody's code.
25
28
  return {
26
- sources: files.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e))),
29
+ sources: files.filter((f) => !isTest(f) && !isGenerated(f) && exts.some((e) => f.endsWith(e))),
27
30
  hasTests: files.some(isTest),
28
31
  };
29
32
  }
@@ -11,6 +11,11 @@ import { runOneShotReview } from "./one-shot-review.js";
11
11
  import { prRequirementChecks } from "./pr-requirements.js";
12
12
  import { prSecurityChecks } from "./pr-security.js";
13
13
  import { sonarCheck } from "./pr-sonar.js";
14
+ import { batchDiff } from "./diff-batches.js";
15
+ import { DIFF_CLIP_CHARS } from "../prompts/diff-clip.js";
16
+
17
+ // KJC-BUG-0378: a reviewer call per batch; past this many, files are left out and named.
18
+ const MAX_REVIEW_BATCHES = 10;
14
19
 
15
20
  const gitDiffOf = async (projectDir, range) => {
16
21
  const res = await runCommand("git", ["diff", range], { cwd: projectDir });
@@ -23,27 +28,45 @@ const finding = (i) => ({
23
28
  description: i.description ?? "", suggested_fix: i.suggested_fix ?? null,
24
29
  });
25
30
 
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 }) {
31
+ /** The review as data: verdict, findings, suggestions, security checks and, apart, the house requirements. */
32
+ export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck, limit = DIFF_CLIP_CHARS, maxBatches = MAX_REVIEW_BATCHES }) {
28
33
  const diff = await gitDiff(projectDir, range);
29
34
  // 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
- });
35
+ // KJC-BUG-0379 (#2263): the house requirements (size, commits, attribution, tests) travel apart:
36
+ // on another repo with other conventions they must not read as findings next to security.
37
+ const policyChecks = await requirements({ range, projectDir, config });
38
+ const checks = [...await security({ diff, projectDir, config }), await sonar({ diff, config, projectDir })];
39
+ // KJC-BUG-0378 (#2263): the whole diff, in batches of whole files that fit the reviewer's
40
+ // prompt; what does not fit is named in the coverage and the verdict cannot be approved.
41
+ const provider = resolveRole(config, "reviewer").provider || null;
42
+ const { batches, coverage } = batchDiff(diff, { limit, maxBatches });
43
+ if (coverage.files.total === 0) throw new Error(`nothing to review — ${range} has no file changes`);
44
+ const records = [];
45
+ for (const batch of batches) {
46
+ records.push(await run({ diff: batch.text, task, config, logger, projectDir, reviewerOverride: provider, persist: false }));
47
+ }
36
48
  return {
37
49
  mode: "third-party",
38
50
  range,
39
- reviewer: record.reviewer,
40
- verdict: { approved: record.verdict === "approved", summary: record.summary ?? "" },
41
- findings: (record.issues ?? []).map(finding),
42
- suggestions: record.suggestions ?? [],
51
+ reviewer: records[0]?.reviewer ?? provider,
52
+ verdict: verdictOf(records, coverage),
53
+ coverage,
54
+ findings: records.flatMap((r) => (r.issues ?? []).map(finding)),
55
+ suggestions: records.flatMap((r) => r.suggestions ?? []),
43
56
  checks,
57
+ policyChecks,
44
58
  };
45
59
  }
46
60
 
61
+ /** Rejected if any batch was; approved only when every file was reviewed; partial otherwise. */
62
+ function verdictOf(records, coverage) {
63
+ let status = "approved";
64
+ if (records.some((r) => r.verdict !== "approved")) status = "rejected";
65
+ else if (coverage.truncated) status = "partial";
66
+ const summary = records.map((r) => r.summary).filter(Boolean).join("\n");
67
+ return { status, approved: status === "approved", summary };
68
+ }
69
+
47
70
  /**
48
71
  * The CLI side: stdout carries exactly one JSON document, logs go to stderr.
49
72
  * Exit 0 whatever the PR's verdict (the agent decides what to post); non-zero
package/src/utils/git.js CHANGED
@@ -307,8 +307,10 @@ export async function hasRemote(cwd = null) {
307
307
  return res.exitCode === 0;
308
308
  }
309
309
 
310
+ // KJC-TSK-1093: no -u, which writes the upstream into .git/config, read-only inside
311
+ // the host sandbox. Nothing in kj reads a feature branch's upstream.
310
312
  export async function pushBranch(branch, cwd = null) {
311
- await runGit(["push", "-u", "origin", branch], cwd ? { cwd } : {});
313
+ await runGit(["push", "origin", branch], cwd ? { cwd } : {});
312
314
  }
313
315
 
314
316
  export async function createPullRequest({ baseBranch, branch, title, body, cwd = null }) {