karajan-code 4.6.3 → 4.8.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.6.3",
3
+ "version": "4.8.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",
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { defaultEnvironment } from "../infrastructure/environment.js";
14
+ import { buildAgentEnv } from "../utils/role-env.js";
14
15
 
15
16
  const MODEL_NOT_SUPPORTED_PATTERNS = [
16
17
  /model.{0,30}is not supported/i,
@@ -49,6 +50,25 @@ export class BaseAgent {
49
50
  * @returns {Promise<CommandRunResult>}
50
51
  */
51
52
  async runCommand(command, args = [], options = {}) {
53
+ // KJC-TSK-0693: the subprocess gets an env allowlist, never the user's
54
+ // whole environment. security.env_allowlist:false opts out.
55
+ if (this.config?.security?.env_allowlist !== false) {
56
+ // Solomon-arbitrated contract (KJC-TSK-0693): an explicit env WITH a
57
+ // PATH is a COMPLETE child env (agents build those from process.env and
58
+ // deliberately strip vars — cleanExecaOpts drops CLAUDECODE, qwen
59
+ // unsets gemini's trust var) and is filtered AS GIVEN. An env WITHOUT
60
+ // a PATH is a partial overlay and merges over the inherited env first,
61
+ // so an overlay caller can never cost the child PATH/HOME.
62
+ const complete = options.env && ("PATH" in options.env || "Path" in options.env);
63
+ const base = complete ? options.env : { ...process.env, ...(options.env || {}) };
64
+ options = {
65
+ ...options,
66
+ env: buildAgentEnv(base, {
67
+ agent: this.name,
68
+ passthrough: this.config?.security?.env_passthrough || [],
69
+ }),
70
+ };
71
+ }
52
72
  return this.environment.runner.run(command, args, options);
53
73
  }
54
74
 
@@ -65,7 +65,7 @@ function formatAiSlopBlock(slop) {
65
65
 
66
66
  function formatInjectionBlock(inj) {
67
67
  if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
68
- const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
68
+ const lines = ["### Prompt-injection scan (agent-context surface: CLAUDE/AGENTS/GEMINI.md, templates, .rulesync, .karajan)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
69
69
  if ((inj.total ?? 0) > 0) {
70
70
  for (const [severity, items] of Object.entries(groupInjectionBySeverity(inj.findings || []))) {
71
71
  if (items.length === 0) continue;
@@ -17,6 +17,15 @@ const SCAN_TARGETS = [
17
17
  { rel: ".karajan/specs", kind: "dir" },
18
18
  { rel: ".karajan/onboarding", kind: "dir" },
19
19
  { rel: ".karajan/domain.md", kind: "file" },
20
+ // KJC-TSK-0695: the agent-context surface — whatever lands in the host
21
+ // agent's context every session is the real injection vector.
22
+ { rel: "CLAUDE.md", kind: "file" },
23
+ { rel: "AGENTS.md", kind: "file" },
24
+ { rel: "GEMINI.md", kind: "file" },
25
+ { rel: ".cursorrules", kind: "file" },
26
+ { rel: path.join(".github", "copilot-instructions.md"), kind: "file" },
27
+ { rel: "templates", kind: "dir" },
28
+ { rel: ".rulesync", kind: "dir" },
20
29
  ];
21
30
 
22
31
  export async function collectInjectionFindings(rootDir, logger = null) {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ai-surface — MCP inventory with drift detection (KJC-TSK-0694).
3
+ *
4
+ * Every new tool an agent gains is one more access. An inventory nobody
5
+ * reconciles is "the feeling of control without the control", so kj check
6
+ * flags what APPEARED since the last run and asks the user to own it.
7
+ * A nudge, never a gate. ~/.claude.json is scoped to its global block plus
8
+ * THIS project's entry — other projects' MCPs are not reachable here.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ const sources = (projectDir, home) => [
16
+ { file: path.join(projectDir, ".mcp.json"), format: "json" },
17
+ { file: path.join(home, ".claude.json"), format: "json" },
18
+ { file: path.join(home, ".codex", "config.toml"), format: "toml" },
19
+ { file: path.join(home, ".gemini", "settings.json"), format: "json" },
20
+ ];
21
+
22
+ function jsonServerNames(text, projectDir) {
23
+ try {
24
+ const parsed = JSON.parse(text);
25
+ const names = new Set(Object.keys(parsed.mcpServers || {}));
26
+ for (const name of Object.keys(parsed.projects?.[projectDir]?.mcpServers || {})) names.add(name);
27
+ return names;
28
+ } catch {
29
+ return new Set();
30
+ }
31
+ }
32
+
33
+ const tomlServerNames = (text) =>
34
+ new Set([...text.matchAll(/^\s*\[mcp_servers\.["']?([^\]"']+)["']?\]/gm)].map((m) => m[1]));
35
+
36
+ /** Sorted `name (config-file)` entries for every MCP reachable in this project. */
37
+ export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir() } = {}) {
38
+ const surface = new Set();
39
+ for (const { file, format } of sources(projectDir, home)) {
40
+ if (!existsSync(file)) continue;
41
+ let text;
42
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
43
+ const names = format === "toml" ? tomlServerNames(text) : jsonServerNames(text, projectDir);
44
+ for (const name of names) surface.add(`${name} (${path.basename(file)})`);
45
+ }
46
+ return [...surface].sort();
47
+ }
48
+
49
+ /**
50
+ * Diff the current surface against the last-seen snapshot and persist the
51
+ * new state. First run records the baseline silently.
52
+ */
53
+ export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json") } = {}) {
54
+ const surface = collectAiSurface({ projectDir, home });
55
+ let state = {};
56
+ try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* first run or corrupt → baseline */ }
57
+ const prev = Array.isArray(state[projectDir]) ? state[projectDir] : null;
58
+ const added = prev ? surface.filter((e) => !prev.includes(e)) : [];
59
+ const removed = prev ? prev.filter((e) => !surface.includes(e)) : [];
60
+ state[projectDir] = surface;
61
+ try {
62
+ mkdirSync(path.dirname(statePath), { recursive: true });
63
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
64
+ } catch { /* read-only FS — inventory still reports, just cannot remember */ }
65
+ return { surface, added, removed, first: !prev };
66
+ }
67
+
68
+ /** One line for kj check output. */
69
+ export function formatAiSurface({ surface, added, removed, first }) {
70
+ const head = `ai surface: ${surface.length} MCP(s)`;
71
+ if (first) return `${head} — baseline recorded`;
72
+ const parts = [head];
73
+ if (added.length) parts.push(`NEW since last check: ${added.join(", ")} — approved by you?`);
74
+ if (removed.length) parts.push(`gone: ${removed.join(", ")}`);
75
+ return parts.join(" · ");
76
+ }
@@ -379,6 +379,7 @@ export function registerMeta(program, { pkgVersion }) {
379
379
  .option("--trend", "Append a sparkline of the harness score across the last 10 runs (uses .karajan/audit-history.db). KJC-TSK-0473.")
380
380
  .option("--report-file <path>", "Write the audit report to disk in addition to stdout. <path> may be a file (extension drives format: .md or .json) or a directory (creates audit-<ISO>.<md|json> inside). $KJ_AUDIT_REPORT_DIR env var is used as default directory if no --report-file is given.")
381
381
  .option("--deterministic-only", "Skip the LLM analysis entirely. Print/persist only the deterministic findings (basalCost, sonar, stack, growth-delta, webperf). Zero tokens spent. Compatible with --report-file and --json.")
382
+ .option("--security", "Focused deterministic security pass: prompt-injection scan over the agent-context surface (CLAUDE.md/AGENTS.md/templates/.rulesync/.karajan) + OSV + Semgrep + Sonar. Skips basal-cost, webperf, madge, knip, ai-slop, harness and the LLM — zero tokens. Designed for agents to self-invoke when a task touches sensitive surface (KJC-TSK-0695).")
382
383
  .option("-y, --yes", "Auto-confirm the 'Continue with LLM analysis?' prompt. Useful in scripts that want the full audit non-interactively. CI/non-TTY paths already auto-confirm without this flag.")
383
384
  .action(async (task, flags) => {
384
385
  await withConfig(pkgVersion, "audit", flags, async ({ config, logger }) => {
@@ -402,6 +403,7 @@ export function registerMeta(program, { pkgVersion }) {
402
403
  noAiSlop: flags.aiSlop === false,
403
404
  reportFile: flags.reportFile || null,
404
405
  deterministicOnly: Boolean(flags.deterministicOnly),
406
+ security: Boolean(flags.security),
405
407
  yes: Boolean(flags.yes),
406
408
  trend: Boolean(flags.trend),
407
409
  });
@@ -237,7 +237,10 @@ function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHar
237
237
  return parts.join(" ");
238
238
  }
239
239
 
240
- export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, noHarness = false, noAiSlop = false, reportFile = null, deterministicOnly = false, yes = false, trend = false, promptFn = null }) {
240
+ export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, noHarness = false, noAiSlop = false, reportFile = null, deterministicOnly = false, yes = false, trend = false, promptFn = null, security = false }) {
241
+ // KJC-TSK-0695: --security = the focused pass an agent self-invokes.
242
+ // Deterministic by definition (zero tokens) and skips non-security stages.
243
+ if (security) { deterministicOnly = true; noHarness = true; noAiSlop = true; }
241
244
  // --agent-readiness is a STANDALONE, deterministic, LLM-free audit
242
245
  // dimension. It scores any third-party repo for AI-agent readability
243
246
  // (llms.txt presence, page token budgets, robots allowlist, etc.).
@@ -281,6 +284,7 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
281
284
  noOsv,
282
285
  noSemgrep,
283
286
  noAiSlop,
287
+ ...(security ? { securityOnly: true } : {}),
284
288
  };
285
289
  const deterministicCtx = await role.collectDeterministic(roleInput);
286
290
  const deterministicMd = formatDeterministicSummary(deterministicCtx);
@@ -6,21 +6,26 @@
6
6
 
7
7
  import { checkHarden } from "../harden/check.js";
8
8
  import { collectMethodStats, formatMethodStats } from "../checks/method.js";
9
+ import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
9
10
 
10
11
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
11
12
  const result = await checkHarden({ projectDir, profile });
12
13
  // KJC-TSK-0689 (MG-D): method adherence is VISIBILITY, not a gate — it
13
14
  // rides along in check output but never affects the exit code.
14
15
  const method = await collectMethodStats({ projectDir }).catch(() => null);
16
+ // KJC-TSK-0694: same deal for the MCP inventory — a nudge, never a gate.
17
+ let aiSurface = null;
18
+ try { aiSurface = checkAiSurface({ projectDir }); } catch { /* inventory is best-effort */ }
15
19
 
16
20
  if (json) {
17
- logger.info?.(JSON.stringify({ ...result, method }));
21
+ logger.info?.(JSON.stringify({ ...result, method, aiSurface }));
18
22
  return result.ok ? 0 : 1;
19
23
  }
20
24
 
21
25
  logger.info?.(`kj check (${profile})`);
22
26
  for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
23
27
  if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
28
+ if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
24
29
  if (!result.ok) logger.info?.("Harness drift detected — run `kj harden` to repair.");
25
30
  else logger.info?.("Harness OK.");
26
31
  return result.ok ? 0 : 1;
@@ -77,8 +77,11 @@ const UNDEF_CHECK_FLAGS = [
77
77
  ];
78
78
 
79
79
  function applyRoleOverrides(out, flags) {
80
+ // A provider override is always a string (`--security codex`). A bare
81
+ // boolean means the flag belongs to the command itself (`kj audit
82
+ // --security`, KJC-TSK-0695) and must not become a provider.
80
83
  for (const [flag, role] of ROLE_PROVIDER_FLAGS) {
81
- if (flags[flag]) out.roles[role].provider = flags[flag];
84
+ if (typeof flags[flag] === "string" && flags[flag]) out.roles[role].provider = flags[flag];
82
85
  }
83
86
  // coder/reviewer also update top-level aliases
84
87
  if (flags.coder) out.coder = flags.coder;
@@ -79,6 +79,7 @@ const B = {
79
79
  [
80
80
  "These findings are never overridable — not even by Solomon arbitration.",
81
81
  "PII is never logged; new dependencies need a reason and a pinned version.",
82
+ "Sensitive surface (auth, user input, secrets, network, deps)? Self-invoke `kj audit --security` — deterministic, zero tokens: prompt-injection over the agent-context files + OSV + Semgrep + Sonar — and remediate the findings before requesting review.",
82
83
  ],
83
84
  "findings with severity, category and file:line — or an explicit 'no security surface touched'."),
84
85
  },
@@ -66,6 +66,8 @@ Invariants (the git gates enforce these — they are not suggestions):
66
66
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
67
67
  - Security findings are never overridable — not even by arbitration. You
68
68
  absorb the security role: \`kj brief security\` states what must be true.
69
+ Task touches auth, user input, secrets, network or deps? Run
70
+ \`kj audit --security\` (zero tokens) and remediate BEFORE the review.
69
71
  - A design decision the card's AC don't cover belongs to the USER: record
70
72
  it as a proposed ADR (\`kj adr add\`) and ask — never bury it in a PR
71
73
  bullet. An oversized-diff warning is not an opinion either: partition,
@@ -48,13 +48,17 @@ export class AuditRole extends AgentRole {
48
48
  * execute() would gather internally)
49
49
  */
50
50
  async collectDeterministic(input) {
51
+ // KJC-TSK-0695: securityOnly = the focused pass an agent self-invokes
52
+ // when a task touches sensitive surface. Only the security collectors
53
+ // run (sonar, osv, semgrep, injection) — zero tokens, fast.
54
+ const securityOnly = typeof input === "object" ? Boolean(input?.securityOnly) : false;
51
55
  const noSonar = typeof input === "object" ? Boolean(input?.noSonar) : false;
52
56
  const noOsv = typeof input === "object" ? Boolean(input?.noOsv) : false;
53
57
  const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
54
- const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
55
- const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
58
+ const noMadge = (typeof input === "object" ? Boolean(input?.noMadge) : false) || securityOnly;
59
+ const noKnip = (typeof input === "object" ? Boolean(input?.noKnip) : false) || securityOnly;
56
60
  const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
57
- const noAiSlop = typeof input === "object" ? Boolean(input?.noAiSlop) : false;
61
+ const noAiSlop = (typeof input === "object" ? Boolean(input?.noAiSlop) : false) || securityOnly;
58
62
  const projectDir = this.config?.projectDir || process.cwd();
59
63
  let basalCost = null;
60
64
  let growthDelta = null;
@@ -67,11 +71,13 @@ export class AuditRole extends AgentRole {
67
71
  let deadExports = null;
68
72
  let injectionFindings = null;
69
73
  let aiSlop = null;
70
- try {
71
- basalCost = await measureBasalCost(projectDir);
72
- const previous = await loadPreviousAudit(projectDir);
73
- growthDelta = computeGrowthDelta(basalCost, previous);
74
- } catch { /* basal cost is best-effort */ }
74
+ if (!securityOnly) {
75
+ try {
76
+ basalCost = await measureBasalCost(projectDir);
77
+ const previous = await loadPreviousAudit(projectDir);
78
+ growthDelta = computeGrowthDelta(basalCost, previous);
79
+ } catch { /* basal cost is best-effort */ }
80
+ }
75
81
  try {
76
82
  stack = await detectProjectStack(projectDir);
77
83
  } catch { /* stack detect is best-effort */ }
@@ -80,9 +86,11 @@ export class AuditRole extends AgentRole {
80
86
  sonarFindings = await collectSonarFindings(this.config, this.logger);
81
87
  } catch { /* sonar fetch is best-effort */ }
82
88
  }
83
- try {
84
- webperf = collectWebPerfInput(stack, this.config);
85
- } catch { /* webperf input is best-effort */ }
89
+ if (!securityOnly) {
90
+ try {
91
+ webperf = collectWebPerfInput(stack, this.config);
92
+ } catch { /* webperf input is best-effort */ }
93
+ }
86
94
  // OSV vulnerabilities — KJC-TSK-0365. Best-effort: missing
87
95
  // osv-scanner binary returns available:false and the audit
88
96
  // continues without the section.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * role-env — env allowlist for agent subprocesses (KJC-TSK-0693).
3
+ *
4
+ * Agents inherit only what their function requires: system essentials, the
5
+ * spawned CLI's own auth/config vars, and KJ_* lane vars. Long-lived secrets
6
+ * that no agent needs (cloud keys, registry tokens, DB URLs) never reach the
7
+ * child, so a compromised agent or an injected prompt cannot exfiltrate them.
8
+ * Escapes: `security.env_passthrough` (exact names or trailing-* globs) and
9
+ * `security.env_allowlist: false` to disable filtering entirely.
10
+ */
11
+
12
+ const EXACT = new Set([
13
+ "PATH", "Path", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "COLORTERM", "LANG",
14
+ "TZ", "TMPDIR", "TMP", "TEMP", "EDITOR", "CI", "OLLAMA_HOST",
15
+ // Windows child-process essentials: executable resolution and profile dirs.
16
+ "SystemRoot", "SYSTEMROOT", "windir", "COMSPEC", "ComSpec", "PATHEXT",
17
+ "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
18
+ // SSH agent socket: local-only handle, needed for git fetch/pull over SSH
19
+ // inside worktrees. Unusable off-machine, unlike a long-lived token.
20
+ "SSH_AUTH_SOCK", "SSH_AGENT_PID",
21
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
22
+ "http_proxy", "https_proxy", "no_proxy",
23
+ ]);
24
+
25
+ // Per-CLI auth/config families. GOOGLE_ covers GOOGLE_APPLICATION_CREDENTIALS
26
+ // (gemini via Vertex); npm_ covers npm_config_* that Node tooling reads.
27
+ const PREFIXES = [
28
+ "LC_", "XDG_", "KJ_", "NODE_", "npm_",
29
+ "ANTHROPIC_", "CLAUDE", "OPENAI_", "CODEX", "GEMINI", "GOOGLE_",
30
+ "QWEN", "DASHSCOPE_", "OPENCODE", "AIDER_", "OPENROUTER_",
31
+ ];
32
+
33
+ // Vars only ONE agent authenticates with — granting them globally would
34
+ // defeat the point (the coder does not need your GitHub token; copilot does).
35
+ const AGENT_EXTRAS = {
36
+ copilot: ["GITHUB_", "GH_"],
37
+ };
38
+
39
+ const matches = (pattern, key) =>
40
+ pattern.endsWith("*") ? key.startsWith(pattern.slice(0, -1)) : key === pattern;
41
+
42
+ /**
43
+ * Filter an environment down to what the given agent's subprocess needs.
44
+ * @param {Record<string,string|undefined>} base - env to filter
45
+ * @param {{agent?: string|null, passthrough?: string[]}} [opts]
46
+ * @returns {Record<string,string|undefined>}
47
+ */
48
+ export function buildAgentEnv(base = process.env, { agent = null, passthrough = [] } = {}) {
49
+ const prefixes = [...PREFIXES, ...(AGENT_EXTRAS[agent] || [])];
50
+ const out = {};
51
+ for (const [key, value] of Object.entries(base)) {
52
+ if (EXACT.has(key) || prefixes.some((p) => key.startsWith(p)) || passthrough.some((p) => matches(p, key))) {
53
+ out[key] = value;
54
+ }
55
+ }
56
+ return out;
57
+ }