karajan-code 4.19.0 → 4.20.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.19.0",
3
+ "version": "4.20.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",
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { defaultEnvironment } from "../infrastructure/environment.js";
14
14
  import { buildAgentEnv } from "../utils/role-env.js";
15
+ import { isModelCompatible } from "../config/role-resolver.js";
15
16
 
16
17
  const MODEL_NOT_SUPPORTED_PATTERNS = [
17
18
  /model.{0,30}is not supported/i,
@@ -95,10 +96,19 @@ export class BaseAgent {
95
96
  * @returns {string|null}
96
97
  */
97
98
  getRoleModel(role) {
98
- const roleModel = this.config?.roles?.[role]?.model;
99
- if (roleModel) return roleModel;
100
- if (role === "reviewer") return this.config?.reviewer_options?.model || null;
101
- return this.config?.coder_options?.model || null;
99
+ const roleModel =
100
+ this.config?.roles?.[role]?.model ||
101
+ (role === "reviewer" ? this.config?.reviewer_options?.model : this.config?.coder_options?.model) ||
102
+ null;
103
+ // KJC-BUG-0144 (campo, issue #1465): un default por-rol (start: haiku)
104
+ // puede caer sobre un provider de OTRA familia (agy, codex...) — el
105
+ // modelo se descarta con aviso y el agente usa su propio default,
106
+ // nunca se le pasa un --model ajeno.
107
+ if (roleModel && !isModelCompatible(this.name, roleModel)) {
108
+ this.logger?.warn?.(`model "${roleModel}" belongs to another family — dropping it for ${this.name} (role ${role})`);
109
+ return null;
110
+ }
111
+ return roleModel;
102
112
  }
103
113
 
104
114
  /**
@@ -77,9 +77,26 @@ export class CodexAgent extends BaseAgent {
77
77
  }
78
78
 
79
79
  async _exec(task, model, role) {
80
+ // KJC-BUG-0143 (campo, issue #1471): codex exec >= 0.146 rechaza
81
+ // --full-auto ("unexpected argument"); el auto-approve moderno para un
82
+ // coder que escribe es --sandbox workspace-write. Los CLIs antiguos no
83
+ // conocen --sandbox: si lo rechazan, se reintenta con el flag legacy.
84
+ const autoApprove = role !== "reviewer" && this.isAutoApproveEnabled(role);
85
+ const res = await this._execOnce(task, model, role, { autoApprove, legacyAutoFlag: false });
86
+ if (!res.ok && autoApprove && /unexpected argument '--sandbox'/.test(res.error || "")) {
87
+ this.logger?.warn("codex CLI sin --sandbox — reintentando con el flag legacy --full-auto");
88
+ return this._execOnce(task, model, role, { autoApprove, legacyAutoFlag: true });
89
+ }
90
+ return res;
91
+ }
92
+
93
+ async _execOnce(task, model, role, { autoApprove, legacyAutoFlag }) {
80
94
  const args = ["exec", "--skip-git-repo-check"];
81
95
  if (model) args.push("--model", model);
82
- if (role !== "reviewer" && this.isAutoApproveEnabled(role)) args.push("--full-auto");
96
+ if (autoApprove) {
97
+ if (legacyAutoFlag) args.push("--full-auto");
98
+ else args.push("--sandbox", "workspace-write");
99
+ }
83
100
  args.push("-");
84
101
  const res = await this.runCommand(resolveBin("codex"), args, {
85
102
  onOutput: task.onOutput,
@@ -38,6 +38,7 @@ export function formatDeterministicSummary(ctx) {
38
38
  if (ctx.sonarFindings) lines.push(...formatSonarBlock(ctx.sonarFindings));
39
39
  if (ctx.osvFindings) lines.push(...formatOsvBlock(ctx.osvFindings));
40
40
  if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
41
+ if (ctx.infraFindings) lines.push(...formatInfraBlock(ctx.infraFindings));
41
42
  if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
42
43
  if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
43
44
  if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
@@ -133,6 +134,25 @@ function formatSemgrepBlock(semgrepFindings) {
133
134
  return lines;
134
135
  }
135
136
 
137
+ // INF-C (KJC-TSK-0760): checkov misconfigs — same shape as the semgrep
138
+ // block; a not-applicable repo (no infra markers) declares it in one line.
139
+ function formatInfraBlock(infraFindings) {
140
+ if (!infraFindings.available) {
141
+ return ["### Infra misconfigurations (checkov)", `- Status: not available — ${infraFindings.reason || "checkov not installed"}`, ""];
142
+ }
143
+ const lines = ["### Infra misconfigurations (checkov)"];
144
+ lines.push(`- Total findings: ${infraFindings.total ?? 0}`);
145
+ const cap = MAX_SAMPLE_SEMGREP_PER_SEVERITY * 3;
146
+ for (const f of (infraFindings.findings || []).slice(0, cap)) {
147
+ const lineSuffix = f.line ? `:${f.line}` : "";
148
+ lines.push(` - [${f.severity}] ${f.file}${lineSuffix} [${f.rule}] ${f.message}${f.guideline ? ` (${f.guideline})` : ""}`);
149
+ }
150
+ const overflow = (infraFindings.total ?? 0) - cap;
151
+ if (overflow > 0) lines.push(` - ... and ${overflow} more`);
152
+ lines.push("");
153
+ return lines;
154
+ }
155
+
136
156
  function formatOsvBlock(osvFindings) {
137
157
  if (!osvFindings.available) {
138
158
  return ["### OSV Vulnerabilities", `- Status: not available — ${osvFindings.reason || "osv-scanner not installed"}`, ""];
@@ -298,6 +318,7 @@ export function deterministicContextHasFindings(ctx) {
298
318
  if (ctx.sonarFindings?.qualityGate?.status === "ERROR") return true;
299
319
  if (ctx.osvFindings?.available && (ctx.osvFindings.total ?? 0) > 0) return true;
300
320
  if (ctx.semgrepFindings?.available && (ctx.semgrepFindings.total ?? 0) > 0) return true;
321
+ if (ctx.infraFindings?.available && (ctx.infraFindings.total ?? 0) > 0) return true;
301
322
  if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
302
323
  if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
303
324
  if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Checkov infra-misconfiguration collector for `kj audit` (INF-C,
3
+ * KJC-TSK-0760, epic KJC-PCS-0078).
4
+ *
5
+ * One tool covers the whole infra surface — terraform, kubernetes, helm,
6
+ * kustomize, ansible, dockerfiles — so checkov is THE infra scanner (tfsec
7
+ * and tflint stay out of scope: single dependency to learn and install).
8
+ * Findings enter the audit through the SAME best-effort channel as
9
+ * semgrep-findings (KJC-TSK-0366): no infra markers → not-applicable (zero
10
+ * noise, checkov never runs); missing binary → declared with the install
11
+ * command; non-zero exit with JSON on stdout (checkov's failed-checks
12
+ * behavior) rescued; unparseable output degrades SAYING so.
13
+ */
14
+
15
+ import { runGoverned, jobsCap } from "../utils/tool-governor.js";
16
+ import { detectInfraMarkers } from "../utils/project-detect.js";
17
+
18
+ const SCAN_TIMEOUT_MS = 120_000;
19
+ const INSTALL_HINT = "install via 'pipx install checkov' or 'pip install checkov'";
20
+
21
+ /**
22
+ * Run checkov on the project and return a structured result.
23
+ *
24
+ * @param {string} projectDir - absolute path to the project root
25
+ * @param {object} [logger]
26
+ * @returns {Promise<{available: boolean, reason?: string, total?: number, findings?: object[]}>}
27
+ */
28
+ export async function collectInfraFindings(projectDir, logger = null) {
29
+ // KJC-BUG-0122 doctrine: external scanners never fire from the unit suite.
30
+ if (process.env.VITEST && process.env.KJ_ALLOW_REAL_SCANS !== "1") {
31
+ return { available: false, reason: "real scans disabled under the test runner (set KJ_ALLOW_REAL_SCANS=1 to opt in)" };
32
+ }
33
+ if (!(await detectInfraMarkers(projectDir))) {
34
+ return { available: false, reason: "no infra markers (terraform/helm/kustomize/ansible) — not applicable" };
35
+ }
36
+ let stdout;
37
+ try {
38
+ const { stdout: out } = await runGoverned(
39
+ "checkov",
40
+ ["-d", projectDir || ".", "--output", "json", "--quiet", "--compact", `--parallel-runner-count=${jobsCap()}`],
41
+ { cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
42
+ );
43
+ stdout = out;
44
+ } catch (err) {
45
+ if (err.code === "ENOENT") {
46
+ if (logger?.warn) logger.warn(`checkov not found in PATH — ${INSTALL_HINT}`);
47
+ return { available: false, reason: `checkov not installed — ${INSTALL_HINT}` };
48
+ }
49
+ if (err.killed) {
50
+ if (logger?.warn) logger.warn(`checkov timed out after ${SCAN_TIMEOUT_MS}ms`);
51
+ return { available: false, reason: `checkov timed out after ${SCAN_TIMEOUT_MS}ms` };
52
+ }
53
+ // checkov exits non-zero when checks fail, but still emits the report.
54
+ if (typeof err.stdout === "string" && err.stdout.trim().length > 0) {
55
+ stdout = err.stdout;
56
+ } else {
57
+ if (logger?.warn) logger.warn(`checkov failed: ${err.message}`);
58
+ return { available: false, reason: `checkov failed: ${err.message}` };
59
+ }
60
+ }
61
+
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(stdout);
65
+ } catch (err) {
66
+ if (logger?.warn) logger.warn(`checkov output not parseable: ${err.message}`);
67
+ return { available: false, reason: "checkov output not parseable as JSON" };
68
+ }
69
+
70
+ return { available: true, ...parseCheckovOutput(parsed) };
71
+ }
72
+
73
+ /**
74
+ * Convert checkov's JSON (object for one framework, array for several)
75
+ * into the flat findings list the audit prompt consumes. Checkov OSS may
76
+ * omit severity — WARNING is the honest default.
77
+ *
78
+ * @param {object|object[]} raw
79
+ * @returns {{total: number, findings: object[]}}
80
+ */
81
+ export function parseCheckovOutput(raw) {
82
+ const reports = Array.isArray(raw) ? raw : [raw];
83
+ const findings = [];
84
+ for (const report of reports) {
85
+ const failed = report?.results?.failed_checks;
86
+ if (!Array.isArray(failed)) continue;
87
+ for (const c of failed) {
88
+ findings.push({
89
+ rule: c.check_id || "(unknown)",
90
+ severity: String(c.severity || "WARNING").toUpperCase(),
91
+ framework: report.check_type || "infra",
92
+ file: String(c.file_path || "").replace(/^\//, ""),
93
+ line: Array.isArray(c.file_line_range) ? (c.file_line_range[0] ?? 0) : 0,
94
+ message: c.check_name || "",
95
+ guideline: c.guideline || null,
96
+ });
97
+ }
98
+ }
99
+ return { total: findings.length, findings };
100
+ }
@@ -20,8 +20,13 @@
20
20
 
21
21
  /**
22
22
  * Check if a model string is compatible with an agent provider.
23
- * Only returns false when the model clearly belongs to a DIFFERENT provider.
23
+ * Only returns false when the model clearly belongs to a DIFFERENT family.
24
24
  * Returns true if we can't determine or if the model is ambiguous.
25
+ *
26
+ * KJC-BUG-0144: exported and family-based — an agent may serve another
27
+ * family's models (agy is Google's Antigravity, gemini family), and the
28
+ * same check now guards BaseAgent.getRoleModel so per-role defaults
29
+ * (e.g. start's haiku) never reach a provider from another family.
25
30
  */
26
31
  const AGENT_MODEL_SIGNATURES = {
27
32
  claude: ["claude", "sonnet", "opus", "haiku"],
@@ -29,21 +34,26 @@ const AGENT_MODEL_SIGNATURES = {
29
34
  gemini: ["gemini", "flash-"]
30
35
  };
31
36
 
32
- function isModelCompatible(agent, model) {
33
- if (!model || !agent) return true;
34
- const lower = model.toLowerCase();
37
+ // Single-family CLIs: they only serve their own vendor's models. Agents NOT
38
+ // in this map (aider, opencode, copilot, kimi...) are multi-model hosts —
39
+ // they route to arbitrary models, so any model name is allowed there.
40
+ const AGENT_FAMILY = { claude: "claude", codex: "codex", gemini: "gemini", agy: "gemini" };
35
41
 
36
- // Check if model clearly belongs to a different provider
37
- for (const [provider, signatures] of Object.entries(AGENT_MODEL_SIGNATURES)) {
38
- if (provider === agent) continue;
39
- if (signatures.some(s => lower.includes(s))) {
40
- // Model belongs to a different provider — incompatible
41
- return false;
42
- }
42
+ function modelFamily(model) {
43
+ const lower = model.toLowerCase();
44
+ for (const [family, signatures] of Object.entries(AGENT_MODEL_SIGNATURES)) {
45
+ if (signatures.some(s => lower.includes(s))) return family;
43
46
  }
47
+ return null;
48
+ }
44
49
 
45
- // Model doesn't clearly belong to any other provider — allow it
46
- return true;
50
+ export function isModelCompatible(agent, model) {
51
+ if (!model || !agent) return true;
52
+ const agentFamily = AGENT_FAMILY[agent];
53
+ if (!agentFamily) return true; // multi-model host — bring your own model
54
+ const family = modelFamily(model);
55
+ if (!family) return true; // ambiguous/custom/local — allow it
56
+ return family === agentFamily;
47
57
  }
48
58
 
49
59
  // Roles that inherit provider/model from the coder when not explicitly configured
@@ -35,8 +35,10 @@ import { fileURLToPath } from "node:url";
35
35
  const here = dirname(fileURLToPath(import.meta.url));
36
36
  export const STATE = join(here, "sentinel-state.json");
37
37
  export const ROOT = join(here, "..", "..");
38
- export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$/;
39
- export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp)$/;
38
+ // INF-A (KJC-TSK-0758): infra IS source — terraform/k8s/shell/Docker edits
39
+ // engage the method like app code; *_test.* files (terratest) are tests.
40
+ export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$|_test\\.[a-z]+$/;
41
+ export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp|tf|hcl|ya?ml|sh)$|(^|\\/)(Dockerfile|Makefile|Vagrantfile)$/;
40
42
  export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
41
43
  export const BASE_BRANCHES = new Set(["main", "master"]);
42
44
  export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
@@ -226,6 +228,43 @@ process.stdin.on("end", () => {
226
228
  process.exit(2);
227
229
  }
228
230
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
231
+ // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
232
+ // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
233
+ // prefijo jamas llegaba a process.env y el escape era inalcanzable
234
+ // (deadlock real del gate de release: la landing solo se pone verde
235
+ // tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
236
+ // escape es una excepcion CONSCIENTE y auditada, no un boundary de
237
+ // seguridad (security sigue sin escape y la autoprotecion corre antes),
238
+ // asi que el TEXTO del comando es fuente valida: solo asignaciones
239
+ // LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
240
+ // sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
241
+ // prefijo shell no alcanzaria a los comandos posteriores (catch de
242
+ // codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
243
+ // escape por texto se rechaza y queda la via env de siempre.
244
+ const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
245
+ const ESC_TAB = String.fromCharCode(9);
246
+ const ESC_NL = String.fromCharCode(10);
247
+ const ESC_BT = String.fromCharCode(96);
248
+ const escOn = (name) => {
249
+ if (process.env[name] === "1") return true;
250
+ let found = false;
251
+ let i = 0;
252
+ while (i < CMD_TEXT.length) {
253
+ while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
254
+ const start = i;
255
+ while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
256
+ const tok = CMD_TEXT.slice(start, i);
257
+ const eq = tok.indexOf("=");
258
+ if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
259
+ if (tok === name + "=1") found = true;
260
+ }
261
+ if (!found) return false;
262
+ for (const c of CMD_TEXT) {
263
+ if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT) return false;
264
+ if (c === "$" || c === "(" || c === ")") return false;
265
+ }
266
+ return true;
267
+ };
229
268
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
230
269
  // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
231
270
  const laneOf = (p) => {
@@ -243,7 +282,7 @@ process.stdin.on("end", () => {
243
282
  return d ? foreignLane(d) : null;
244
283
  };
245
284
  const laneDeny = (what, lane) => {
246
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
285
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
247
286
  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
287
  return true;
249
288
  };
@@ -270,7 +309,7 @@ process.stdin.on("end", () => {
270
309
  // directorio se deniega conservadoramente — el remedio es no
271
310
  // necesitarlo (git -C, npm --prefix, rutas absolutas).
272
311
  if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
273
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
312
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
274
313
  else {
275
314
  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
315
  process.exit(2);
@@ -289,7 +328,7 @@ process.stdin.on("end", () => {
289
328
  // de tokens de abajo; el residuo (expansion anidada en segmentos
290
329
  // runner) queda documentado como en la regla de ficheros PROTECTED.
291
330
  if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
292
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
331
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
293
332
  else {
294
333
  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
334
  process.exit(2);
@@ -355,7 +394,7 @@ process.stdin.on("end", () => {
355
394
  return flush();
356
395
  };
357
396
  if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
358
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
397
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
359
398
  else {
360
399
  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).");
361
400
  process.exit(2);
@@ -364,7 +403,7 @@ process.stdin.on("end", () => {
364
403
  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]*$/;
365
404
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
366
405
  if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
367
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
406
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
368
407
  else {
369
408
  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).");
370
409
  process.exit(2);
@@ -406,13 +445,13 @@ process.stdin.on("end", () => {
406
445
  let v = null;
407
446
  try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
408
447
  const secure = !!(v && v.class === "security");
409
- if (!secure && process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
448
+ if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
410
449
  else {
411
450
  console.error("kj sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)"));
412
451
  process.exit(2);
413
452
  }
414
453
  } else if (pres.status !== 0) {
415
- if (process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
454
+ if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
416
455
  else {
417
456
  console.error("kj sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada).");
418
457
  process.exit(2);
@@ -426,7 +465,7 @@ process.stdin.on("end", () => {
426
465
  const branch = branchOf();
427
466
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
428
467
  if (why) {
429
- if (process.env.KJ_ALLOW_NO_CARD === "1") { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
468
+ if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
430
469
  console.error(why === "base"
431
470
  ? "kj sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)"
432
471
  : "kj sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)");
@@ -437,7 +476,7 @@ process.stdin.on("end", () => {
437
476
  if (tool === "Bash") {
438
477
  const cmd = String(input.command || "");
439
478
  if (PUBLISH.test(cmd)) {
440
- if (process.env.KJ_ALLOW_RELEASE === "1") { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
479
+ if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
441
480
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
442
481
  if (res.error || res.status === null) process.exit(0);
443
482
  if (res.status !== 0) {
@@ -6,6 +6,7 @@ import { collectSonarFindings } from "../audit/sonar-findings.js";
6
6
  import { collectWebPerfInput } from "../audit/webperf-input.js";
7
7
  import { collectOsvFindings } from "../audit/osv-findings.js";
8
8
  import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
9
+ import { collectInfraFindings } from "../audit/infra-findings.js";
9
10
  import { collectCircularDeps } from "../audit/circular-deps.js";
10
11
  import { collectDeadExports } from "../audit/dead-exports.js";
11
12
  import { collectInjectionFindings } from "../audit/injection-findings.js";
@@ -58,6 +59,7 @@ export class AuditRole extends AgentRole {
58
59
  const noMadge = (typeof input === "object" ? Boolean(input?.noMadge) : false) || securityOnly;
59
60
  const noKnip = (typeof input === "object" ? Boolean(input?.noKnip) : false) || securityOnly;
60
61
  const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
62
+ const noInfraScan = typeof input === "object" ? Boolean(input?.noInfraScan) : false;
61
63
  const noAiSlop = (typeof input === "object" ? Boolean(input?.noAiSlop) : false) || securityOnly;
62
64
  const projectDir = this.config?.projectDir || process.cwd();
63
65
  let basalCost = null;
@@ -70,6 +72,7 @@ export class AuditRole extends AgentRole {
70
72
  let circularDeps = null;
71
73
  let deadExports = null;
72
74
  let injectionFindings = null;
75
+ let infraFindings = null;
73
76
  let aiSlop = null;
74
77
  if (!securityOnly) {
75
78
  try {
@@ -127,6 +130,14 @@ export class AuditRole extends AgentRole {
127
130
  injectionFindings = await collectInjectionFindings(projectDir, this.logger);
128
131
  } catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
129
132
  }
133
+ // Checkov infra misconfigs — INF-C (KJC-TSK-0760). Security surface, so
134
+ // it ALSO runs in securityOnly. Best-effort: no infra markers or missing
135
+ // checkov return available:false declaring why.
136
+ if (!noInfraScan) {
137
+ try {
138
+ infraFindings = await collectInfraFindings(projectDir, this.logger);
139
+ } catch { /* checkov is best-effort */ }
140
+ }
130
141
  // AI-slop tells (KJC-TSK-0503). Deterministic, offline, regex-based.
131
142
  // Local equivalent of Deslopify-style review. Best-effort.
132
143
  if (!noAiSlop) {
@@ -134,7 +145,7 @@ export class AuditRole extends AgentRole {
134
145
  aiSlop = await collectAiSlop(projectDir);
135
146
  } catch { /* ai-slop scan is best-effort */ }
136
147
  }
137
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, aiSlop };
148
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, infraFindings, aiSlop };
138
149
  }
139
150
 
140
151
  /**
@@ -24,7 +24,14 @@ const COVERAGE_COMMANDS = {
24
24
  rspec: "bundle exec rspec 2>&1",
25
25
  phpunit: "vendor/bin/phpunit --coverage-text 2>&1",
26
26
  "dotnet-test": "dotnet test --collect:\"XPlat Code Coverage\" 2>&1",
27
- "dart-test": "dart test 2>&1"
27
+ "dart-test": "dart test 2>&1",
28
+ // INF-B: the basic suite ALWAYS runs first; checkov is the additive deep
29
+ // scan. If checkov is missing this chain fails and the brief falls back to
30
+ // the basic suite alone (declared degradation, never green-by-omission).
31
+ "terraform-validate": "terraform init -backend=false -input=false 2>&1 && terraform validate 2>&1 && checkov -d . --compact --quiet 2>&1",
32
+ "helm-lint": "helm lint . 2>&1 && checkov -d . --compact --quiet 2>&1",
33
+ "kustomize-build": "kustomize build . > /dev/null && checkov -d . --compact --quiet 2>&1",
34
+ "ansible-lint": "ansible-lint 2>&1 && checkov -d . --compact --quiet 2>&1"
28
35
  };
29
36
 
30
37
  const TEST_COMMANDS = {
@@ -39,7 +46,12 @@ const TEST_COMMANDS = {
39
46
  rspec: "bundle exec rspec 2>&1",
40
47
  phpunit: "vendor/bin/phpunit 2>&1",
41
48
  "dotnet-test": "dotnet test 2>&1",
42
- "dart-test": "dart test 2>&1"
49
+ "dart-test": "dart test 2>&1",
50
+ // INF-B (KJC-TSK-0759): infra suites — validity IS the test floor.
51
+ "terraform-validate": "terraform init -backend=false -input=false 2>&1 && terraform validate 2>&1",
52
+ "helm-lint": "helm lint . 2>&1",
53
+ "kustomize-build": "kustomize build . > /dev/null && echo kustomize build: OK",
54
+ "ansible-lint": "ansible-lint 2>&1"
43
55
  };
44
56
 
45
57
  export class TesterRole extends AgentRole {
@@ -82,9 +94,14 @@ export class TesterRole extends AgentRole {
82
94
  if (detection.hasTests && detection.framework) {
83
95
  const coverageCmd = COVERAGE_COMMANDS[detection.framework];
84
96
  const testCmd = TEST_COMMANDS[detection.framework];
97
+ // INF-B: infra suites have no node_modules — the step-0 contract is
98
+ // "the tool exists or you SAY which one is missing", never a fake green.
99
+ const step0 = detection.language === "infra"
100
+ ? "**Step 0**: Verify the SUITE tool is installed (`which terraform`/`helm`/`kustomize`/`ansible-lint`). If the suite tool is MISSING, do NOT fake a green: report it in `failures` with the exact install command and set tests_pass: false. `checkov` (the deep scan below) is OPTIONAL: if it is missing, run the basic suite as the fallback and SAY the deep scan was skipped (degraded, never green-by-omission)."
101
+ : "**Step 0**: If node_modules/ does not exist, run `npm install` (or `pnpm install`) first.";
85
102
  sections.push(
86
103
  `## Detected test framework: ${detection.framework} (${detection.language})`,
87
- "**Step 0**: If node_modules/ does not exist, run `npm install` (or `pnpm install`) first.",
104
+ step0,
88
105
  `**Step 1**: Run the test suite with coverage:`,
89
106
  "```bash",
90
107
  coverageCmd || testCmd,
@@ -42,6 +42,18 @@ const LANGUAGE_MARKERS = [
42
42
  { file: "pubspec.yaml", framework: "dart-test", language: "dart" },
43
43
  ];
44
44
 
45
+ // --- Infra (INF-B, KJC-TSK-0759) ---
46
+ // Checked AFTER app-language markers: a mixed repo keeps its app framework.
47
+ // Only unequivocal markers — a stray .yaml never classifies as infra.
48
+ const INFRA_MARKERS = [
49
+ { file: "*.tf", glob: true, framework: "terraform-validate", language: "infra" },
50
+ { file: "terraform", framework: "terraform-validate", language: "infra" },
51
+ { file: "Chart.yaml", framework: "helm-lint", language: "infra" },
52
+ { file: "kustomization.yaml", framework: "kustomize-build", language: "infra" },
53
+ { file: "kustomization.yml", framework: "kustomize-build", language: "infra" },
54
+ { file: "ansible.cfg", framework: "ansible-lint", language: "infra" },
55
+ ];
56
+
45
57
  /** Test file patterns per language (used by TDD policy). */
46
58
  export const TEST_PATTERNS_BY_LANGUAGE = {
47
59
  javascript: ["/tests/", "/__tests__/", ".test.", ".spec."],
@@ -55,6 +67,7 @@ export const TEST_PATTERNS_BY_LANGUAGE = {
55
67
  php: ["/tests/", "/test/", "Test.php"],
56
68
  swift: ["/Tests/", "Tests.swift"],
57
69
  dart: ["/test/", "_test.dart"],
70
+ infra: ["/tests/", "/test/", "_test."],
58
71
  };
59
72
 
60
73
  function frameworkFromConfig(filename) {
@@ -93,7 +106,29 @@ export async function detectTestFramework(cwd = process.cwd()) {
93
106
  }
94
107
 
95
108
  // 3. Multi-language: check project markers
96
- for (const marker of LANGUAGE_MARKERS) {
109
+ const lang = await matchMarkers(cwd, LANGUAGE_MARKERS);
110
+ if (lang) return lang;
111
+
112
+ // 4. Infra (INF-B): terraform/helm/kustomize/ansible have their own suite.
113
+ const infra = await matchMarkers(cwd, INFRA_MARKERS);
114
+ if (infra) return infra;
115
+
116
+ return { hasTests: false, framework: null, language: null };
117
+ }
118
+
119
+ /**
120
+ * True when the project carries unequivocal infra markers (terraform, helm,
121
+ * kustomize, ansible) — regardless of the app framework the repo may also
122
+ * have. Used by INF-C's audit collector to gate the checkov scan.
123
+ * @param {string} cwd
124
+ * @returns {Promise<boolean>}
125
+ */
126
+ export async function detectInfraMarkers(cwd = process.cwd()) {
127
+ return Boolean(await matchMarkers(cwd, INFRA_MARKERS));
128
+ }
129
+
130
+ async function matchMarkers(cwd, markers) {
131
+ for (const marker of markers) {
97
132
  try {
98
133
  if (marker.glob) {
99
134
  const entries = await fs.readdir(cwd);
@@ -107,8 +142,7 @@ export async function detectTestFramework(cwd = process.cwd()) {
107
142
  }
108
143
  } catch { /* not found */ }
109
144
  }
110
-
111
- return { hasTests: false, framework: null, language: null };
145
+ return null;
112
146
  }
113
147
 
114
148
  /**