karajan-code 4.18.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 +1 -1
- package/scripts/dual-publish.mjs +67 -0
- package/scripts/verify-pack.mjs +8 -5
- package/src/agents/base-agent.js +14 -4
- package/src/agents/codex-agent.js +18 -1
- package/src/audit/deterministic-summary.js +21 -0
- package/src/audit/infra-findings.js +100 -0
- package/src/checks/guarantee-level.js +38 -0
- package/src/cli/register-meta.js +1 -1
- package/src/cli/register-roles-skills.js +17 -1
- package/src/commands/agents.js +4 -2
- package/src/commands/doctor.js +16 -0
- package/src/commands/review-gate.js +23 -0
- package/src/config/role-resolver.js +23 -13
- package/src/environment/briefs.js +3 -0
- package/src/environment/playbook.js +3 -1
- package/src/harden/sentinel-hooks.js +107 -13
- package/src/harden/workflow-engine.js +9 -9
- package/src/harden/workflow-templates.js +2 -2
- package/src/mutate/diff-scope.js +6 -2
- package/src/review/mutation-pregate.js +48 -0
- package/src/roles/audit-role.js +12 -1
- package/src/roles/tester-role.js +20 -3
- package/src/start/sweep.js +46 -4
- package/src/utils/project-detect.js +37 -3
- package/src/utils/update-check.js +25 -6
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dual-publish (MIG-A, KJC-TSK-0751, épica KJC-PCS-0077, ADR 0004) — el
|
|
4
|
+
* MISMO contenido sale bajo dos nombres npm durante la migración al scope:
|
|
5
|
+
* karajan-code (legacy, publish normal) y @karajan-family/code (scoped).
|
|
6
|
+
*
|
|
7
|
+
* Uso:
|
|
8
|
+
* node scripts/dual-publish.mjs --pack-only # genera+verifica el tarball scoped
|
|
9
|
+
* node scripts/dual-publish.mjs --publish --otp=XXX # publica el scoped (tras publicar el legacy)
|
|
10
|
+
*
|
|
11
|
+
* El swap del name es EN SITIO con restauración en finally: un crash a mitad
|
|
12
|
+
* no deja el manifest renombrado. La verificación es el MISMO verify-pack
|
|
13
|
+
* (name-agnóstico desde la parte 1): empaqueta, instala aislado y corre kj.
|
|
14
|
+
* El PRIMER publish del scoped lo hace el usuario (cuenta karajan-family,
|
|
15
|
+
* security key); los siguientes van con la sesión normal.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { join, dirname, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
export const LEGACY_NAME = "karajan-code";
|
|
23
|
+
export const SCOPED_NAME = "@karajan-family/code";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ejecuta `fn` con el manifest renombrado al scoped (+ publishConfig public,
|
|
27
|
+
* obligatorio para scoped) y lo RESTAURA byte a byte pase lo que pase.
|
|
28
|
+
*/
|
|
29
|
+
export async function withScopedName(repoRoot, fn) {
|
|
30
|
+
const pkgPath = join(repoRoot, "package.json");
|
|
31
|
+
const original = readFileSync(pkgPath, "utf8");
|
|
32
|
+
const pkg = JSON.parse(original);
|
|
33
|
+
if (pkg.name !== LEGACY_NAME) {
|
|
34
|
+
throw new Error(`dual-publish: el manifest dice "${pkg.name}" y solo se renombra desde ${LEGACY_NAME} — jamás a ciegas`);
|
|
35
|
+
}
|
|
36
|
+
// publishConfig se MERGEA, no se pisa (catch de codex): un registry/tag
|
|
37
|
+
// preexistente debe sobrevivir al publish scoped.
|
|
38
|
+
writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, name: SCOPED_NAME, publishConfig: { ...pkg.publishConfig, access: "public" } }, null, 2)}\n`, "utf8");
|
|
39
|
+
try {
|
|
40
|
+
return await fn();
|
|
41
|
+
} finally {
|
|
42
|
+
writeFileSync(pkgPath, original, "utf8");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// argv[1] normalizado (catch de codex): node lo absolutiza hoy, pero el
|
|
47
|
+
// guard no debe depender de ese detalle de implementación.
|
|
48
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
49
|
+
if (isMain) {
|
|
50
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const publish = args.includes("--publish");
|
|
53
|
+
const otp = args.find((a) => a.startsWith("--otp="))?.slice(6);
|
|
54
|
+
await withScopedName(repoRoot, async () => {
|
|
55
|
+
console.log(`dual-publish: manifest → ${SCOPED_NAME} (temporal)`);
|
|
56
|
+
// La E2E real: empaqueta el scoped, instálalo aislado y corre kj.
|
|
57
|
+
execFileSync("node", [join(repoRoot, "scripts", "verify-pack.mjs")], { cwd: repoRoot, stdio: "inherit" });
|
|
58
|
+
if (publish) {
|
|
59
|
+
const pubArgs = ["publish", "--ignore-scripts", ...(otp ? [`--otp=${otp}`] : [])];
|
|
60
|
+
console.log(`dual-publish: npm ${pubArgs.join(" ").replace(/--otp=\S+/, "--otp=***")}`);
|
|
61
|
+
execFileSync("npm", pubArgs, { cwd: repoRoot, stdio: "inherit" });
|
|
62
|
+
} else {
|
|
63
|
+
console.log("dual-publish: --pack-only — verificado y SIN publicar (usa --publish en la release)");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
console.log(`dual-publish: manifest restaurado a ${LEGACY_NAME} ✓`);
|
|
67
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -27,6 +27,9 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
28
|
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
29
29
|
const expectedVersion = pkg.version;
|
|
30
|
+
// MIG-A (KJC-TSK-0751): el nombre sale del manifest — el MISMO verify-pack
|
|
31
|
+
// valida el tarball unscoped y el @karajan-family/* del dual-publish.
|
|
32
|
+
const pkgName = pkg.name;
|
|
30
33
|
|
|
31
34
|
// Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
|
|
32
35
|
// runs otherwise) and force a non-interactive, quiet npm.
|
|
@@ -54,13 +57,13 @@ let gTmp = null;
|
|
|
54
57
|
let pnpmTmp = null;
|
|
55
58
|
let qsTmp = null;
|
|
56
59
|
try {
|
|
57
|
-
console.log(`verify-pack: packing
|
|
60
|
+
console.log(`verify-pack: packing ${pkgName}@${expectedVersion}…`);
|
|
58
61
|
// --json gives us the exact filename without parsing human output.
|
|
59
62
|
const packOut = run("npm", ["pack", "--json", "--silent"], { cwd: repoRoot });
|
|
60
63
|
const filename = JSON.parse(packOut)[0]?.filename;
|
|
61
64
|
if (!filename) fail("npm pack did not report a filename", packOut);
|
|
62
|
-
// npm
|
|
63
|
-
// the file lands in cwd under
|
|
65
|
+
// npm --json reports the exact filename (scoped names normalized to
|
|
66
|
+
// scope-name-x.y.z.tgz); the file lands in cwd under that name.
|
|
64
67
|
tgzPath = path.join(repoRoot, filename);
|
|
65
68
|
if (!fs.existsSync(tgzPath)) fail(`packed tarball not found at ${tgzPath}`);
|
|
66
69
|
|
|
@@ -93,7 +96,7 @@ try {
|
|
|
93
96
|
const { scanPaths, loadPrivacyList } = await import(
|
|
94
97
|
path.join(repoRoot, "src", "privacy", "scan.js")
|
|
95
98
|
);
|
|
96
|
-
const shippedDir = path.join(tmpDir, "node_modules", "
|
|
99
|
+
const shippedDir = path.join(tmpDir, "node_modules", ...pkgName.split("/"));
|
|
97
100
|
const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
|
|
98
101
|
const pBlocks = pFindings.filter((f) => f.severity === "block");
|
|
99
102
|
if (pBlocks.length > 0) {
|
|
@@ -265,7 +268,7 @@ try {
|
|
|
265
268
|
);
|
|
266
269
|
}
|
|
267
270
|
|
|
268
|
-
console.log(`\n✓ verify-pack:
|
|
271
|
+
console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
|
|
269
272
|
} finally {
|
|
270
273
|
if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
|
|
271
274
|
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
package/src/agents/base-agent.js
CHANGED
|
@@ -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 =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarantee level en runtime (KJC-TSK-0756, hallazgo de campo de Pedro):
|
|
3
|
+
* la doctrina "kj jamás finge una supervisión que no puede aplicar" vivía
|
|
4
|
+
* solo en el README — ahora kj doctor la DECLARA para el host actual, con
|
|
5
|
+
* el porqué. El acoplamiento a Claude es UNA capa (tier A: hooks síncronos
|
|
6
|
+
* bloqueantes, que hoy solo ese harness expone) y es decisión de ADR
|
|
7
|
+
* (v4.13), no accidente; el tier B (git gates) es el suelo agnóstico.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { detectHostAgent } from "../utils/agent-detect.js";
|
|
12
|
+
|
|
13
|
+
/** Pure: computes the three tiers from observable facts. */
|
|
14
|
+
export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkflow }) {
|
|
15
|
+
const tierA = host === "claude" && harnessWired
|
|
16
|
+
? { active: true, reason: "Claude Code expone hooks síncronos bloqueantes y el harness del Sentinel está instalado (kj harden)" }
|
|
17
|
+
: host === "claude"
|
|
18
|
+
? { active: false, reason: "host Claude sin harness instalado — corre kj harden para activar la supervisión en el turno" }
|
|
19
|
+
: { active: false, reason: `el host ${host ?? "desconocido"} no expone hooks síncronos bloqueantes — no se finge la supervisión; el tier B sigue siendo el suelo de garantía` };
|
|
20
|
+
const tierB = { active: true, reason: "los gates viven en el repositorio, no en el harness: cualquier host, el diff violador no entra" };
|
|
21
|
+
const tierC = policyDeclared && policyWorkflow
|
|
22
|
+
? { active: true, reason: "kj-policy.yml re-verifica el PR contra la policy declarada (merge-blocking)" }
|
|
23
|
+
: policyDeclared
|
|
24
|
+
? { active: false, reason: "hay policy declarada pero sin workflow de CI — corre kj harden para sembrar kj-policy.yml" }
|
|
25
|
+
: { active: false, reason: "sin .karajan/policy.yml declarada — el tier C solo existe donde hay policy que verificar" };
|
|
26
|
+
return { host: host ?? null, tierA, tierB, tierC };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Facts from disk + env; injectable for tests. */
|
|
30
|
+
export function collectGuaranteeFacts({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
31
|
+
const { detectHost = detectHostAgent, exists = existsSync } = deps;
|
|
32
|
+
return guaranteeLevel({
|
|
33
|
+
host: detectHost(),
|
|
34
|
+
harnessWired: exists(join(projectDir, ".karajan", "harness", "pretooluse-sentinel.mjs")),
|
|
35
|
+
policyDeclared: exists(join(projectDir, ".karajan", "policy.yml")),
|
|
36
|
+
policyWorkflow: exists(join(projectDir, ".github", "workflows", "kj-policy.yml")),
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -286,7 +286,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
289
|
-
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado) —
|
|
289
|
+
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
|
|
290
290
|
policyCmd.command("eval")
|
|
291
291
|
.description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
|
|
292
292
|
.option("--role <role>", "Rol del agente", "coder")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rolesCommand } from "../commands/roles.js";
|
|
2
|
-
import { agentsCommand } from "../commands/agents.js";
|
|
2
|
+
import { agentsCommand, ASSIGNABLE_ROLES, VALID_PROVIDERS } from "../commands/agents.js";
|
|
3
3
|
import { withConfig } from "./_shared.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -30,6 +30,22 @@ export function registerRolesSkills(program, { pkgVersion }) {
|
|
|
30
30
|
.command("agents [subcommand] [role] [provider]")
|
|
31
31
|
.description("List or change AI agent assignments per role (e.g. kj agents set coder gemini)")
|
|
32
32
|
.option("--global", "Persist change to kj.config.yml (default for CLI)")
|
|
33
|
+
// KJC-TSK-0755: el --help enumera valores REALES (de las constantes, no
|
|
34
|
+
// duplicados a mano) — la firma genérica obligaba a preguntar.
|
|
35
|
+
.addHelpText("after", [
|
|
36
|
+
"",
|
|
37
|
+
"Subcommands:",
|
|
38
|
+
" list (default) Show the provider assigned to each role",
|
|
39
|
+
" set Change one: kj agents set <role> <provider>",
|
|
40
|
+
"",
|
|
41
|
+
`Roles: ${ASSIGNABLE_ROLES.join(", ")}`,
|
|
42
|
+
`Providers: ${VALID_PROVIDERS.join(", ")} (kj doctor shows which are installed)`,
|
|
43
|
+
"",
|
|
44
|
+
"Examples:",
|
|
45
|
+
" kj agents # current assignments",
|
|
46
|
+
" kj agents set reviewer codex",
|
|
47
|
+
" kj agents set solomon agy --global",
|
|
48
|
+
].join("\n"))
|
|
33
49
|
.action(async (subcommand, role, provider, flags) => {
|
|
34
50
|
await withConfig(pkgVersion, "agents", {}, async ({ config }) => {
|
|
35
51
|
await agentsCommand({ config, subcommand: subcommand || "list", role, provider, global: flags.global });
|
package/src/commands/agents.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { loadConfig, writeConfig, getConfigPath, getProjectConfigPath, loadProjectConfig, resolveRole } from "../config.js";
|
|
2
2
|
import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Exportados para que el --help se autoalimente de las constantes reales
|
|
5
|
+
// (KJC-TSK-0755, hallazgo de campo: la firma genérica obligaba a preguntar).
|
|
6
|
+
export const ASSIGNABLE_ROLES = [
|
|
5
7
|
"coder", "reviewer", "planner", "refactorer", "triage",
|
|
6
8
|
"researcher", "tester", "security", "solomon"
|
|
7
9
|
];
|
|
8
10
|
|
|
9
|
-
const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
|
|
11
|
+
export const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
|
|
10
12
|
|
|
11
13
|
export function listAgents(config, sessionOverrides = {}, projectConfig = null) {
|
|
12
14
|
return ASSIGNABLE_ROLES.map((role) => {
|
package/src/commands/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import readline from "node:readline";
|
|
15
15
|
import { runChecks as runCheckPipeline, toLegacyShape } from "../checks/runner.js";
|
|
16
|
+
import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
|
|
16
17
|
import { STATUS } from "../checks/types.js";
|
|
17
18
|
import { getSystemChecks } from "../checks/system.js";
|
|
18
19
|
import { getConfigFileChecks } from "../checks/config-files.js";
|
|
@@ -140,16 +141,31 @@ export async function runChecks({ config }) {
|
|
|
140
141
|
*/
|
|
141
142
|
export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false, projectOnly = false }) {
|
|
142
143
|
const report = await runDoctor({ config, checkOnly, yes, projectOnly });
|
|
144
|
+
// KJC-TSK-0756 (campo, Pedro): el guarantee level del host actual se
|
|
145
|
+
// DECLARA en runtime — la doctrina no vive solo en el README.
|
|
146
|
+
report.guarantee = collectGuaranteeFacts({ projectDir: config?.projectDir || process.cwd() });
|
|
143
147
|
|
|
144
148
|
if (json) {
|
|
145
149
|
console.log(JSON.stringify(report, null, 2));
|
|
146
150
|
} else {
|
|
147
151
|
printHuman(report, { verbose });
|
|
152
|
+
printGuaranteeLevel(report.guarantee);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
return hasBlockingFailure(report) ? 1 : 0;
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
// KJC-TSK-0756: la seccion de guarantee level — consola es el medio del
|
|
159
|
+
// doctor (excepcion no-console de commands).
|
|
160
|
+
function printGuaranteeLevel(g) {
|
|
161
|
+
const mark = (t) => (t.active ? "ACTIVE " : "OFF ");
|
|
162
|
+
console.log();
|
|
163
|
+
console.log(`Guarantee level (host: ${g.host ?? "desconocido"}) — kj jamas finge una supervision que no puede aplicar:`);
|
|
164
|
+
console.log(` ${mark(g.tierA)} A tool-time (Sentinel en el turno): ${g.tierA.reason}`);
|
|
165
|
+
console.log(` ${mark(g.tierB)} B commit-time (git gates, el SUELO): ${g.tierB.reason}`);
|
|
166
|
+
console.log(` ${mark(g.tierC)} C merge-time (re-check en CI): ${g.tierC.reason}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
153
169
|
function hasBlockingFailure(report) {
|
|
154
170
|
return report.checks.some((c) => c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT);
|
|
155
171
|
}
|
|
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
|
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
|
|
16
|
+
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
16
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
17
18
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
18
19
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
@@ -325,6 +326,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
330
|
+
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
331
|
+
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
332
|
+
// cierra ANTES de gastar reviewer; warn viaja como advisory en su task.
|
|
333
|
+
const mut = flags.staged && !flags.range ? await runMutationPregate({ config, projectDir }) : { enabled: false, ok: true };
|
|
334
|
+
if (mut.enabled) {
|
|
335
|
+
if (mut.available === false) {
|
|
336
|
+
console.log(`⚠ mutation pre-gate no disponible: ${mut.reason} — degradado avisando (method_gates.mutation)`);
|
|
337
|
+
} else if (mut.survived.length > 0) {
|
|
338
|
+
console.log(`Mutantes supervivientes en el diff — score ${mut.score ?? "n/a"}%:`);
|
|
339
|
+
for (const m of mut.survived) console.log(` ✗ ${formatSurvivor(m)}`);
|
|
340
|
+
if (!mut.ok) {
|
|
341
|
+
console.log("✗ mutation gate: la suite verde no PRUEBA el cambio — mata los supervivientes antes del review (method_gates.mutation: block).");
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return { verdict: "rejected", reviewer: "mutation", issues: mut.survived.map((m) => ({ severity: "high", file: m.file, description: formatSurvivor(m) })) };
|
|
344
|
+
}
|
|
345
|
+
task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
|
|
346
|
+
+ `Surviving mutants on the changed lines (weak asserts — fold into your review):\n`
|
|
347
|
+
+ mut.survived.map((m) => `- ${formatSurvivor(m)}`).join("\n");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
328
351
|
const record = await runOneShotReview({ diff, task, config, logger, projectDir });
|
|
329
352
|
printVerdict(record);
|
|
330
353
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
@@ -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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
@@ -71,6 +71,9 @@ const B = {
|
|
|
71
71
|
[
|
|
72
72
|
...(tdd ? ["TDD is ON: the failing test exists FIRST, then the code."] : []),
|
|
73
73
|
"The suite is never left red, and never goes green by deleting or skipping tests.",
|
|
74
|
+
...(config?.method_gates?.mutation
|
|
75
|
+
? ["Green is not proof: run `kj mutate --since <base>` after the suite passes and BEFORE review — kill surviving mutants (weak asserts) first."]
|
|
76
|
+
: []),
|
|
74
77
|
],
|
|
75
78
|
"green suite + the list of new/changed tests and what each one pins.");
|
|
76
79
|
},
|
|
@@ -61,7 +61,9 @@ Invariants (the git gates enforce these — they are not suggestions):
|
|
|
61
61
|
\`kj_rag_query\` tool: reach for it before grepping by hand.
|
|
62
62
|
- ${trackingLine(stateBackend, boardName)}
|
|
63
63
|
- Tests prove behavior: the failing test exists first (TDD), and the suite
|
|
64
|
-
is never left red.
|
|
64
|
+
is never left red. Green is not proof: with \`method_gates.mutation\`
|
|
65
|
+
declared, run \`kj mutate --since <base>\` after green and BEFORE the
|
|
66
|
+
review — a surviving mutant is a weak assert; kill it first.
|
|
65
67
|
- Every diff is reviewed by a DIFFERENT AI before it is committed
|
|
66
68
|
(\`kj review --staged\`): verdicts bind to the exact diff — change the code
|
|
67
69
|
and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
|
|
@@ -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
|
-
|
|
39
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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);
|
|
@@ -299,8 +338,63 @@ process.stdin.on("end", () => {
|
|
|
299
338
|
// en cualquier posicion, y una ruta entrecomillada con espacios es
|
|
300
339
|
// invisible al tokenizador: ambas => deny conservador (endpoint
|
|
301
340
|
// pedido por el reviewer; friccion asumida, el remedio es literal).
|
|
302
|
-
|
|
303
|
-
|
|
341
|
+
// KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
|
|
342
|
+
// la comilla de CIERRE de un string + un redirect literal + la de
|
|
343
|
+
// apertura del siguiente creaban la ilusion de ruta con espacios y
|
|
344
|
+
// denegaban el flujo estandar commit -m "..." > log && echo "ok".
|
|
345
|
+
// Se recorre alternando estado (doctrina del parseCommand del motor):
|
|
346
|
+
// solo el CONTENIDO realmente entrecomillado con slash y espacio
|
|
347
|
+
// deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
|
|
348
|
+
// y la comilla sin cerrar (no verificable).
|
|
349
|
+
// ...y por TOKEN completo, no por span: spans adyacentes concatenan
|
|
350
|
+
// ("dir with"" spaces/file" es UNA ruta) — catch de codex.
|
|
351
|
+
const quotedPathWithSpaces = (s) => {
|
|
352
|
+
// Un backslash pegado a comilla o blanco (comilla escapada,
|
|
353
|
+
// espacio escapado) altera donde CIERRA el string o esconde
|
|
354
|
+
// espacios: opaco => deny (doctrina del motor) — catch de codex.
|
|
355
|
+
// Caracteres construidos con fromCharCode: CERO escaping en este
|
|
356
|
+
// template — fuente y artefacto identicos, nada que malinterpretar
|
|
357
|
+
// (el doble-escaping confundio cuatro reviews; solomon desestimo
|
|
358
|
+
// la primera y las demas se volvieron ilegibles igual).
|
|
359
|
+
// El punto escapado de un sed y similares siguen libres.
|
|
360
|
+
const BS = String.fromCharCode(92);
|
|
361
|
+
const TAB = String.fromCharCode(9);
|
|
362
|
+
const NL = String.fromCharCode(10);
|
|
363
|
+
for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
|
|
364
|
+
const n = s[i + 1] || "";
|
|
365
|
+
if (n === '"' || n === "'" || n === " " || n === TAB) return true;
|
|
366
|
+
}
|
|
367
|
+
let q = null;
|
|
368
|
+
let tok = "";
|
|
369
|
+
let hadQuote = false;
|
|
370
|
+
const flush = () => {
|
|
371
|
+
const bad = hadQuote && tok.includes("/") && tok.includes(" ");
|
|
372
|
+
tok = "";
|
|
373
|
+
hadQuote = false;
|
|
374
|
+
return bad;
|
|
375
|
+
};
|
|
376
|
+
for (const ch of s) {
|
|
377
|
+
if (q) {
|
|
378
|
+
if (ch === q) q = null;
|
|
379
|
+
else tok += ch;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (ch === '"' || ch === "'") {
|
|
383
|
+
q = ch;
|
|
384
|
+
hadQuote = true;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
|
|
388
|
+
if (flush()) return true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
tok += ch;
|
|
392
|
+
}
|
|
393
|
+
if (q !== null) return true; // comilla sin cerrar: no verificable
|
|
394
|
+
return flush();
|
|
395
|
+
};
|
|
396
|
+
if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
|
|
397
|
+
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
304
398
|
else {
|
|
305
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).");
|
|
306
400
|
process.exit(2);
|
|
@@ -309,7 +403,7 @@ process.stdin.on("end", () => {
|
|
|
309
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]*$/;
|
|
310
404
|
const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
|
|
311
405
|
if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
312
|
-
if (
|
|
406
|
+
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
313
407
|
else {
|
|
314
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).");
|
|
315
409
|
process.exit(2);
|
|
@@ -344,20 +438,20 @@ process.stdin.on("end", () => {
|
|
|
344
438
|
const actorRole = process.env.KJ_POLICY_ROLE || "coder";
|
|
345
439
|
const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
|
|
346
440
|
if (pres.error || pres.status === null) {
|
|
347
|
-
console.error("kj sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g karajan-code) o retira la policy conscientemente.");
|
|
441
|
+
console.error("kj sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente.");
|
|
348
442
|
process.exit(2);
|
|
349
443
|
}
|
|
350
444
|
if (pres.status === 2) {
|
|
351
445
|
let v = null;
|
|
352
446
|
try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
|
|
353
447
|
const secure = !!(v && v.class === "security");
|
|
354
|
-
if (!secure &&
|
|
448
|
+
if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
355
449
|
else {
|
|
356
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)"));
|
|
357
451
|
process.exit(2);
|
|
358
452
|
}
|
|
359
453
|
} else if (pres.status !== 0) {
|
|
360
|
-
if (
|
|
454
|
+
if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
361
455
|
else {
|
|
362
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).");
|
|
363
457
|
process.exit(2);
|
|
@@ -371,7 +465,7 @@ process.stdin.on("end", () => {
|
|
|
371
465
|
const branch = branchOf();
|
|
372
466
|
const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
|
|
373
467
|
if (why) {
|
|
374
|
-
if (
|
|
468
|
+
if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
|
|
375
469
|
console.error(why === "base"
|
|
376
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)"
|
|
377
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)");
|
|
@@ -382,7 +476,7 @@ process.stdin.on("end", () => {
|
|
|
382
476
|
if (tool === "Bash") {
|
|
383
477
|
const cmd = String(input.command || "");
|
|
384
478
|
if (PUBLISH.test(cmd)) {
|
|
385
|
-
if (
|
|
479
|
+
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
386
480
|
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
387
481
|
if (res.error || res.status === null) process.exit(0);
|
|
388
482
|
if (res.status !== 0) {
|
|
@@ -19,18 +19,18 @@ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkf
|
|
|
19
19
|
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
20
20
|
// bundle SEA no hay package.json junto al módulo y una lectura eager a
|
|
21
21
|
// nivel de módulo tumbaba el binario entero al cargar (ENOENT /package.json).
|
|
22
|
-
let
|
|
23
|
-
function
|
|
24
|
-
if (
|
|
22
|
+
let _ownPkg;
|
|
23
|
+
function ownPkg() {
|
|
24
|
+
if (_ownPkg === undefined) {
|
|
25
25
|
try {
|
|
26
|
-
|
|
26
|
+
_ownPkg = JSON.parse(
|
|
27
27
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"),
|
|
28
|
-
)
|
|
28
|
+
);
|
|
29
29
|
} catch {
|
|
30
|
-
|
|
30
|
+
_ownPkg = null;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
return
|
|
33
|
+
return _ownPkg;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const BLOCK_VERSION = 1;
|
|
@@ -81,9 +81,9 @@ export function installWorkflows({
|
|
|
81
81
|
// PL-C: el tier C solo existe donde el proyecto DECLARA policy. Versión a
|
|
82
82
|
// pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
|
|
83
83
|
// recurso inalcanzable en los caminos reales.
|
|
84
|
-
const pinned = kjVersion ??
|
|
84
|
+
const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
|
|
85
85
|
const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
|
|
86
|
-
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned) }
|
|
86
|
+
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
|
|
87
87
|
: null;
|
|
88
88
|
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
|
|
89
89
|
const results = [];
|
|
@@ -57,7 +57,7 @@ export const WORKFLOWS = [
|
|
|
57
57
|
// PL-C (KJC-TSK-0735) — tier C del ADR 0001: re-check merge-blocking en CI
|
|
58
58
|
// (cubre el hook local manipulado). Solo se siembra con policy declarada;
|
|
59
59
|
// npx PINEADO a la versión del kj que corrió harden — jamás @latest en CI.
|
|
60
|
-
export const policyWorkflowFor = (kjVersion) => [
|
|
60
|
+
export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
|
|
61
61
|
"name: Policy",
|
|
62
62
|
"on:",
|
|
63
63
|
" pull_request:",
|
|
@@ -77,7 +77,7 @@ export const policyWorkflowFor = (kjVersion) => [
|
|
|
77
77
|
" env:",
|
|
78
78
|
" BASE_REF: ${{ github.base_ref }}",
|
|
79
79
|
" run: |",
|
|
80
|
-
` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes
|
|
80
|
+
` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes ${kjName}@${kjVersion}"; fi`,
|
|
81
81
|
' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
|
|
82
82
|
"", // línea en blanco pre-marcador: prettier la exige tras un block scalar
|
|
83
83
|
].join("\n");
|
package/src/mutate/diff-scope.js
CHANGED
|
@@ -89,8 +89,12 @@ export function buildScope({ language, files, ranges }) {
|
|
|
89
89
|
* the injectable git runner (defaults to `runCommand`).
|
|
90
90
|
* @param {{since: string, language: string, toRef?: string, run?: Function}} params
|
|
91
91
|
*/
|
|
92
|
-
export async function getDiffScope({ since, language, toRef = "HEAD", run = runCommand }) {
|
|
93
|
-
|
|
92
|
+
export async function getDiffScope({ since, language, toRef = "HEAD", staged = false, run = runCommand }) {
|
|
93
|
+
// MUT-A (KJC-TSK-0716): staged=true scopea al ÍNDICE (git diff --cached) —
|
|
94
|
+
// lo que el review gate evalúa. since..HEAD con since=HEAD sería un diff
|
|
95
|
+
// VACÍO y el pre-gate no mutaría nada (catch de codex, agravado).
|
|
96
|
+
const args = staged ? ["diff", "--unified=0", "--cached"] : ["diff", "--unified=0", `${since}..${toRef}`];
|
|
97
|
+
const result = await run("git", args).catch(
|
|
94
98
|
() => null,
|
|
95
99
|
);
|
|
96
100
|
if (!result || result.exitCode !== 0) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation pre-gate (MUT-A, KJC-TSK-0716) — audita "tests prove behavior":
|
|
3
|
+
* asserts flojos dejan mutantes vivos con la suite verde. OPT-IN
|
|
4
|
+
* (method_gates.mutation: warn|block — cuesta minutos) y SOLO en --staged:
|
|
5
|
+
* supervivientes como advisory al reviewer; block cierra ANTES con la
|
|
6
|
+
* lista exacta. Indisponible = degrada avisando, nunca en silencio.
|
|
7
|
+
*/
|
|
8
|
+
import { runMutation } from "../mutate/runner.js";
|
|
9
|
+
import { getDiffScope } from "../mutate/diff-scope.js";
|
|
10
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
11
|
+
import { getMutationTool } from "../mutate/tool-registry.js";
|
|
12
|
+
|
|
13
|
+
export const formatSurvivor = (s) =>
|
|
14
|
+
`${s.file}:${s.line} (${s.mutator ?? s.status ?? "?"}) — mátalo con un assert que distinga el cambio`;
|
|
15
|
+
|
|
16
|
+
async function defaultMutate({ projectDir }) {
|
|
17
|
+
const { language } = await detectProjectStack(projectDir);
|
|
18
|
+
const tool = getMutationTool(language);
|
|
19
|
+
if (!tool.supported) throw new Error(`lenguaje no soportado (${language ?? "desconocido"}) — ${tool.reason}`);
|
|
20
|
+
const scope = await getDiffScope({ staged: true, language });
|
|
21
|
+
if (scope.empty) return { result: { score: 100, killed: 0, total: 0, survived: [] } };
|
|
22
|
+
const outcome = await runMutation({ binary: tool.binary, args: scope.args, cwd: projectDir });
|
|
23
|
+
if (!outcome.result) throw new Error(`la herramienta ${tool.id} no produjo un informe legible`);
|
|
24
|
+
return outcome;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @returns {Promise<{enabled: boolean, ok: boolean, available?: boolean, mode?: string, survived?: object[], score?: number, reason?: string}>}
|
|
29
|
+
*/
|
|
30
|
+
export async function runMutationPregate({ config = {}, projectDir = process.cwd(), deps = {} } = {}) {
|
|
31
|
+
const mode = config?.method_gates?.mutation;
|
|
32
|
+
if (mode !== "warn" && mode !== "block") return { enabled: false, ok: true };
|
|
33
|
+
const { mutateFn = defaultMutate } = deps;
|
|
34
|
+
let outcome;
|
|
35
|
+
try {
|
|
36
|
+
outcome = await mutateFn({ projectDir });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// Sin herramienta no hay medición: se degrada DICIENDO qué red cayó.
|
|
39
|
+
return { enabled: true, ok: true, available: false, mode, reason: err.message };
|
|
40
|
+
}
|
|
41
|
+
// Payload malformado del runner = misma degradación que el error (catch
|
|
42
|
+
// de codex): jamás crashear el review gate por un informe roto.
|
|
43
|
+
if (!outcome?.result || !Array.isArray(outcome.result.survived ?? [])) {
|
|
44
|
+
return { enabled: true, ok: true, available: false, mode, reason: "el runner de mutación devolvió un informe ilegible" };
|
|
45
|
+
}
|
|
46
|
+
const { survived = [], score } = outcome.result;
|
|
47
|
+
return { enabled: true, available: true, mode, survived, score, ok: mode === "warn" || survived.length === 0 };
|
|
48
|
+
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -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
|
/**
|
package/src/roles/tester-role.js
CHANGED
|
@@ -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
|
-
|
|
104
|
+
step0,
|
|
88
105
|
`**Step 1**: Run the test suite with coverage:`,
|
|
89
106
|
"```bash",
|
|
90
107
|
coverageCmd || testCmd,
|
package/src/start/sweep.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// brief+tests; existing/legacy = + drift, harden advisory, rag + qmd status.
|
|
8
8
|
// Collaborators are injected (opts.deps) so it stays trivially testable.
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
+
import { readdir } from "node:fs/promises";
|
|
11
|
+
import { join, relative } from "node:path";
|
|
10
12
|
import { execFileSync } from "node:child_process";
|
|
11
13
|
|
|
12
14
|
import { collectAll } from "../onboarder/collectors/index.js";
|
|
@@ -23,11 +25,50 @@ const CODE_EXT = new Set([
|
|
|
23
25
|
".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".go",
|
|
24
26
|
".rs", ".java", ".php", ".rb", ".c", ".cc", ".cpp", ".cs",
|
|
25
27
|
]);
|
|
28
|
+
// KJC-BUG-0141 — infra projects (k8s/terraform/shell/Docker) ARE source code,
|
|
29
|
+
// and code nested deeper than the display tree's maxDepth must still count.
|
|
30
|
+
const INFRA_EXT = new Set([".yaml", ".yml", ".tf", ".hcl", ".sh"]);
|
|
31
|
+
const INFRA_NAMES = new Set(["Dockerfile", "Makefile", "Vagrantfile"]);
|
|
32
|
+
const SCAN_IGNORED = new Set([
|
|
33
|
+
"node_modules", "dist", "build", "out", "vendor", "coverage", "target", "__pycache__",
|
|
34
|
+
]);
|
|
35
|
+
const SCAN_MAX_DEPTH = 6;
|
|
26
36
|
const SCAFFOLD_MAX_FILES = 3;
|
|
27
37
|
const CI_MARKERS = [".github/workflows", ".gitlab-ci.yml"];
|
|
28
38
|
|
|
29
39
|
const safe = async (fn) => { try { return await fn(); } catch { return null; } };
|
|
30
40
|
|
|
41
|
+
const isTestPath = (p) => {
|
|
42
|
+
const posix = p.replaceAll("\\", "/");
|
|
43
|
+
return /\.(test|spec)\./.test(posix) || /(^|\/)(test|tests|__tests__)\//.test(posix);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deep source counter with its own walk: the display tree stops at depth 2,
|
|
48
|
+
* which made backend/app/… projects look empty (KJC-BUG-0141).
|
|
49
|
+
* @returns {Promise<{code: number, infra: number}>}
|
|
50
|
+
*/
|
|
51
|
+
export async function deepSourceScan(projectDir, { maxDepth = SCAN_MAX_DEPTH } = {}) {
|
|
52
|
+
const counts = { code: 0, infra: 0 };
|
|
53
|
+
const walk = async (dir, depth) => {
|
|
54
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
55
|
+
for (const ent of entries) {
|
|
56
|
+
if (ent.name.startsWith(".") || SCAN_IGNORED.has(ent.name)) continue;
|
|
57
|
+
const full = join(dir, ent.name);
|
|
58
|
+
if (ent.isDirectory()) {
|
|
59
|
+
if (depth < maxDepth) await walk(full, depth + 1);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!ent.isFile() || isTestPath(relative(projectDir, full))) continue;
|
|
63
|
+
const ext = ent.name.slice(ent.name.lastIndexOf("."));
|
|
64
|
+
if (CODE_EXT.has(ext)) counts.code += 1;
|
|
65
|
+
else if (INFRA_EXT.has(ext) || INFRA_NAMES.has(ent.name)) counts.infra += 1;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
await walk(projectDir, 0);
|
|
69
|
+
return counts;
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
/** Count real source files in a tree bundle, skipping tests. */
|
|
32
73
|
export function countCodeFiles(tree = []) {
|
|
33
74
|
let n = 0;
|
|
@@ -35,8 +76,7 @@ export function countCodeFiles(tree = []) {
|
|
|
35
76
|
if (node.kind === "dir") n += countCodeFiles(node.children);
|
|
36
77
|
else if (node.kind === "file") {
|
|
37
78
|
const ext = node.path.slice(node.path.lastIndexOf("."));
|
|
38
|
-
|
|
39
|
-
if (CODE_EXT.has(ext) && !isTest) n += 1;
|
|
79
|
+
if (CODE_EXT.has(ext) && !isTestPath(node.path)) n += 1;
|
|
40
80
|
}
|
|
41
81
|
}
|
|
42
82
|
return n;
|
|
@@ -73,20 +113,22 @@ function readRagStatus(projectDir) {
|
|
|
73
113
|
*/
|
|
74
114
|
export async function runReadOnlySweep(projectDir, { declared = null, profile = "standard", deps = {} } = {}) {
|
|
75
115
|
const d = {
|
|
76
|
-
collectAll, detectTestFramework, checkHarden, compareHarden,
|
|
116
|
+
collectAll, detectTestFramework, checkHarden, compareHarden, sourceScan: deepSourceScan,
|
|
77
117
|
detectQmd, ragStatus: readRagStatus, gitAgeDays: lastCommitAgeDays, ...deps,
|
|
78
118
|
};
|
|
79
119
|
|
|
80
120
|
const brief = await safe(() => d.collectAll(projectDir));
|
|
81
121
|
const tests = await safe(() => d.detectTestFramework(projectDir));
|
|
122
|
+
const scan = await safe(() => d.sourceScan(projectDir));
|
|
82
123
|
|
|
83
124
|
const tree = brief?.tree ?? [];
|
|
84
125
|
const present = brief?.configs?.present ?? [];
|
|
85
|
-
const codeFiles = countCodeFiles(tree);
|
|
126
|
+
const codeFiles = scan ? scan.code + scan.infra : countCodeFiles(tree);
|
|
86
127
|
const commitCount = brief?.git?.commitCount ?? 0;
|
|
87
128
|
|
|
88
129
|
const signals = {
|
|
89
130
|
hasSourceCode: codeFiles > 0,
|
|
131
|
+
infraFiles: scan?.infra ?? 0,
|
|
90
132
|
scaffoldingOnly: codeFiles > 0 && codeFiles <= SCAFFOLD_MAX_FILES && commitCount <= 1,
|
|
91
133
|
hasTests: Boolean(tests?.hasTests),
|
|
92
134
|
hasCI: CI_MARKERS.some((m) => present.includes(m)),
|
|
@@ -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
|
-
|
|
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
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { isSea } from "node:sea";
|
|
@@ -8,7 +9,25 @@ const CACHE_FILE = "update-check.json";
|
|
|
8
9
|
// KJC-TSK-0690: 6h — kj can ship several releases a day; a session should
|
|
9
10
|
// not work a full day blind. KJ_NO_UPDATE_CHECK=1 opts out (CI).
|
|
10
11
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
11
|
-
|
|
12
|
+
// MIG-A (KJC-TSK-0751): el nombre npm se lee del PROPIO manifest — con el
|
|
13
|
+
// dual-publish del scope, el mismo código vive como karajan-code y como
|
|
14
|
+
// @karajan-family/code y cada instalación se auto-actualiza por SU nombre.
|
|
15
|
+
// Lazy + fallback: en el bundle SEA no hay package.json junto al módulo.
|
|
16
|
+
let _pkgName;
|
|
17
|
+
export function packageName() {
|
|
18
|
+
if (_pkgName) return _pkgName;
|
|
19
|
+
try {
|
|
20
|
+
_pkgName = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).name || "karajan-code";
|
|
21
|
+
} catch {
|
|
22
|
+
_pkgName = "karajan-code";
|
|
23
|
+
}
|
|
24
|
+
return _pkgName;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// URL del registro con el nombre CODIFICADO — el scoped @karajan-family/code
|
|
28
|
+
// lleva @ y / que rompen la ruta sin encode (catch de codex).
|
|
29
|
+
export const registryLatestUrl = (name = packageName()) =>
|
|
30
|
+
`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
|
|
12
31
|
const CHANGELOG_URL = "https://raw.githubusercontent.com/manufosela/karajan-code/main/CHANGELOG.md";
|
|
13
32
|
const HIGHLIGHT_MAX = 160;
|
|
14
33
|
|
|
@@ -55,10 +74,10 @@ export function updateInstruction({ channel, platform = process.platform }) {
|
|
|
55
74
|
: `Re-run the installer: curl -fsSL ${INSTALL_SH_URL} | sh`;
|
|
56
75
|
}
|
|
57
76
|
if (channel === "npm") {
|
|
58
|
-
return `Run: npm install -g ${
|
|
77
|
+
return `Run: npm install -g ${packageName()}`;
|
|
59
78
|
}
|
|
60
79
|
// Channel unknown — offer both paths, never silently pick a wrong one.
|
|
61
|
-
return `Update: npm install -g ${
|
|
80
|
+
return `Update: npm install -g ${packageName()} (or re-run the binary installer — see README)`;
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/**
|
|
@@ -100,7 +119,7 @@ export async function checkForUpdate(currentVersion) {
|
|
|
100
119
|
// Fetch from npm (timeout 3s, don't block)
|
|
101
120
|
const controller = new AbortController();
|
|
102
121
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
103
|
-
const res = await fetch(
|
|
122
|
+
const res = await fetch(registryLatestUrl(), {
|
|
104
123
|
signal: controller.signal,
|
|
105
124
|
headers: { "Accept": "application/json" },
|
|
106
125
|
});
|
|
@@ -174,7 +193,7 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
|
|
|
174
193
|
try {
|
|
175
194
|
// Registry over HTTP, not `npm view` — standalone-binary machines may
|
|
176
195
|
// not have npm at all.
|
|
177
|
-
const res = await fetchFn(
|
|
196
|
+
const res = await fetchFn(registryLatestUrl(), {
|
|
178
197
|
headers: { Accept: "application/json" },
|
|
179
198
|
});
|
|
180
199
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
@@ -213,7 +232,7 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
|
|
|
213
232
|
}
|
|
214
233
|
} else {
|
|
215
234
|
// No stdio:inherit — capture and drop npm's warnings on the success path.
|
|
216
|
-
await run("npm", ["install", "-g", `${
|
|
235
|
+
await run("npm", ["install", "-g", `${packageName()}@latest`]);
|
|
217
236
|
}
|
|
218
237
|
} catch (err) {
|
|
219
238
|
if (err.stdout) logger.error(err.stdout);
|