karajan-code 4.17.0 → 4.18.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/README.md +10 -0
- package/package.json +4 -2
- package/src/agents/claude-agent.js +7 -2
- package/src/cli/register-meta.js +32 -1
- package/src/commands/policy.js +103 -9
- package/src/commands/review-gate.js +88 -4
- package/src/harden/sentinel-hooks.js +36 -1
- package/src/harden/workflow-engine.js +35 -3
- package/src/harden/workflow-templates.js +28 -0
- package/src/policy/add.js +111 -0
- package/src/policy/decisions.js +42 -0
- package/src/policy/engine.js +44 -238
- package/src/policy/exceptions.js +60 -0
- package/src/review/policy-gate.js +26 -0
- package/src/policy/glob.js +0 -44
package/README.md
CHANGED
|
@@ -75,6 +75,16 @@ Karajan **governs** any agent with git gates — the false green is structurally
|
|
|
75
75
|
|
|
76
76
|
Synchronous blocking hooks exist today only in Claude Code. That makes the supported setup explicit: **to guarantee a harness that controls the LLM, use Claude Code as the host** — Claude writes, Codex reviews (a review subprocess needs no hooks), and a third CLI arbitrates when available. On any other host Karajan still governs at the full git-gate level and tells you which level is active — it never pretends a supervision it cannot enforce.
|
|
77
77
|
|
|
78
|
+
With a declared `.karajan/policy.yml`, the policy layer enforces at three tiers, and which tier applies depends on the host:
|
|
79
|
+
|
|
80
|
+
| Tier | Where | Requires | What it guarantees |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **A — tool time** | Sentinel PreToolUse → `kj policy eval --strict` | Claude Code as host (synchronous hooks) | The rule fires BEFORE the damage; the acting agent's role travels in `KJ_POLICY_ROLE` |
|
|
83
|
+
| **B — commit time** | `kj review --staged` / pre-commit → deny + evidentiary exceptions | Any host (the gate lives in git) | The violating diff never enters, no matter who wrote it or what host ran it |
|
|
84
|
+
| **C — merge time** | `kj-policy.yml` CI workflow → `kj policy check --range --strict` | GitHub Actions (seeded by `kj harden`) | Covers a tampered local hook: the PR diff is re-checked against the same policy, merge-blocking |
|
|
85
|
+
|
|
86
|
+
Tier B is the guarantee floor — hosts without hooks lose A, never B; C re-verifies both. Security-class rules and consumer defaults are non-exemptable at every tier: no escape, no arbitration, no grant.
|
|
87
|
+
|
|
78
88
|
## Headless mode
|
|
79
89
|
|
|
80
90
|
The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.18.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",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"workspaces": [
|
|
38
38
|
"packages/ai-trash",
|
|
39
39
|
"packages/core",
|
|
40
|
-
"packages/hu-board"
|
|
40
|
+
"packages/hu-board",
|
|
41
|
+
"packages/governance"
|
|
41
42
|
],
|
|
42
43
|
"imports": {
|
|
43
44
|
"#utils/*": "./src/utils/*",
|
|
@@ -100,6 +101,7 @@
|
|
|
100
101
|
},
|
|
101
102
|
"dependencies": {
|
|
102
103
|
"@babel/parser": "^7.29.7",
|
|
104
|
+
"@karajan-family/governance": "^0.1.0",
|
|
103
105
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
104
106
|
"better-sqlite3": "^12.10.0",
|
|
105
107
|
"chokidar": "^5.0.0",
|
|
@@ -365,6 +365,11 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
365
365
|
async _runTaskExec(task, model, _role) {
|
|
366
366
|
const args = buildPromptArgs(task);
|
|
367
367
|
if (model) args.push("--model", model);
|
|
368
|
+
// PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
|
|
369
|
+
// si su harness corre hooks (claude -p), el tier A evalúa la policy con
|
|
370
|
+
// el rol que ACTÚA. El rol del ORQUESTADOR es autoritativo: task.env no
|
|
371
|
+
// puede spoofearlo (catch de codex: sería un bypass de reglas por rol).
|
|
372
|
+
const roleEnv = task.role ? { ...task.env, KJ_POLICY_ROLE: task.role } : task.env;
|
|
368
373
|
|
|
369
374
|
// Use stream-json when onOutput is provided to get real-time feedback
|
|
370
375
|
if (task.onOutput) {
|
|
@@ -374,7 +379,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
374
379
|
onOutput: streamFilter,
|
|
375
380
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
376
381
|
timeout: task.timeoutMs,
|
|
377
|
-
env:
|
|
382
|
+
env: roleEnv,
|
|
378
383
|
cwd: task.cwd
|
|
379
384
|
}));
|
|
380
385
|
const raw = pickOutput(res);
|
|
@@ -385,7 +390,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
385
390
|
|
|
386
391
|
// Without streaming, use json output to get structured response via stderr
|
|
387
392
|
args.push("--output-format", "json");
|
|
388
|
-
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env:
|
|
393
|
+
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: roleEnv, cwd: task.cwd }));
|
|
389
394
|
const raw = pickOutput(res);
|
|
390
395
|
const output = extractTextFromStreamJson(raw);
|
|
391
396
|
const usage = extractUsageFromStreamJson(raw);
|
package/src/cli/register-meta.js
CHANGED
|
@@ -27,6 +27,7 @@ import { huCommand } from "../commands/hu.js";
|
|
|
27
27
|
import { worktreeCommand } from "../commands/worktree.js";
|
|
28
28
|
import { addAdr, listAdrs } from "../environment/adr.js";
|
|
29
29
|
import { formatAdvancedIndex } from "../commands/advanced.js";
|
|
30
|
+
import { policyAddCommand } from "../policy/add.js";
|
|
30
31
|
import { withConfig } from "./_shared.js";
|
|
31
32
|
import { existsSync } from "node:fs";
|
|
32
33
|
import { join } from "node:path";
|
|
@@ -298,9 +299,39 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
298
299
|
process.exitCode = await policyCommand({ action: "eval", config, flags });
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
policyCmd.command("grant")
|
|
303
|
+
.description("Concede una excepción PERMANENTE con caducidad a una regla NO-security: quién (identidad), regla exacta, justificación y hasta cuándo — GOV-B")
|
|
304
|
+
.option("--rule <rule_id>", "Regla exacta (p.ej. roles.coder.write.deny)")
|
|
305
|
+
.option("--until <iso>", "Caducidad ISO-8601 (p.ej. 2026-09-01T00:00:00Z)")
|
|
306
|
+
.option("--reason <text>", "Justificación escrita en el momento")
|
|
307
|
+
.action(async (flags) => {
|
|
308
|
+
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
309
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
310
|
+
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
policyCmd.command("add")
|
|
314
|
+
.description("Traduce una regla hablada al vocabulario cerrado de policy.yml — propone el diff y SOLO escribe con --yes (PL-C)")
|
|
315
|
+
.argument("<text...>", "La regla en lenguaje natural")
|
|
316
|
+
.option("--yes", "Aplicar el diff propuesto")
|
|
317
|
+
.action(async (textParts, flags) => {
|
|
318
|
+
await withConfig(pkgVersion, "policy-add", flags, async ({ config, logger }) => {
|
|
319
|
+
process.exitCode = await policyAddCommand({ text: textParts.join(" "), config, flags, logger });
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
policyCmd.command("anchor")
|
|
323
|
+
.description("Verifica la cadena del decision log y sella su head-hash en .karajan/policy-anchor.json (trackeado) — anclaje temporal en la historia de git, GOV-C2")
|
|
324
|
+
.action(async (flags) => {
|
|
325
|
+
await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
|
|
326
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
327
|
+
process.exitCode = await policyCommand({ action: "anchor", config, flags });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
301
330
|
policyCmd.command("check")
|
|
302
|
-
.description("Comprueba el diff
|
|
331
|
+
.description("Comprueba el diff (staged, o base...head con --range) contra la policy — warn por defecto; --strict devuelve exit 2 si hay violación enforcement=deny (tier C, merge-blocking)")
|
|
303
332
|
.option("--role <role>", "Rol del agente", "coder")
|
|
333
|
+
.option("--range <ref>", "Evalúa un rango git (p.ej. origin/main...HEAD) en vez del staged (CI)")
|
|
334
|
+
.option("--strict", "Exit 2 si hay violación con enforcement=deny")
|
|
304
335
|
.option("--json", "Machine-readable")
|
|
305
336
|
.action(async (flags) => {
|
|
306
337
|
await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
|
package/src/commands/policy.js
CHANGED
|
@@ -8,20 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
11
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
+
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
|
+
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
12
17
|
|
|
13
18
|
const execFileAsync = promisify(execFile);
|
|
14
19
|
|
|
15
|
-
async function stagedFacts(projectDir, gitFn) {
|
|
20
|
+
async function stagedFacts(projectDir, gitFn, range = null) {
|
|
16
21
|
const run =
|
|
17
22
|
gitFn ||
|
|
18
23
|
(async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
|
19
|
-
|
|
24
|
+
// PL-C (KJC-TSK-0735): en CI no hay staged — con --range se evalúa
|
|
25
|
+
// base...head, el MISMO motor sobre el diff del PR (tier C del ADR 0001).
|
|
26
|
+
const base = range ? ["diff", range] : ["diff", "--cached"];
|
|
27
|
+
const files = (await run([...base, "--name-only"]))
|
|
20
28
|
.split("\n")
|
|
21
29
|
.map((s) => s.trim())
|
|
22
30
|
.filter(Boolean);
|
|
23
31
|
let net = 0;
|
|
24
|
-
for (const line of (await run([
|
|
32
|
+
for (const line of (await run([...base, "--numstat"])).split("\n")) {
|
|
25
33
|
const [a, r] = line.trim().split(/\s+/);
|
|
26
34
|
if (a && a !== "-") net += Number(a) || 0;
|
|
27
35
|
if (r && r !== "-") net -= Number(r) || 0;
|
|
@@ -38,6 +46,83 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
38
46
|
return 1;
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
// GOV-C2 (KJC-TSK-0749): anclaje temporal sin blockchain — verificar la
|
|
50
|
+
// cadena ENTERA y sellar su head-hash en un fichero TRACKEADO por git:
|
|
51
|
+
// reescribir el log de ayer exige reescribir el repo de ayer. Un anchor
|
|
52
|
+
// previo con más entradas que el log actual = truncado ⇒ no se re-sella.
|
|
53
|
+
if (action === "anchor") {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
62
|
+
// Fichero vacío o solo whitespace = sin decisiones (catch de codex:
|
|
63
|
+
// lines.at(-1) undefined reventaba el hash en vez de salir limpio).
|
|
64
|
+
if (lines.length === 0) {
|
|
65
|
+
logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const chain = verifyDecisionChain(lines);
|
|
69
|
+
if (!chain.ok) {
|
|
70
|
+
logger.error?.(`✗ policy anchor: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado; NO se sella`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const anchorFile = join(projectDir, ".karajan", "policy-anchor.json");
|
|
74
|
+
try {
|
|
75
|
+
const prev = JSON.parse(readFileSync(anchorFile, "utf8"));
|
|
76
|
+
if (Number.isFinite(prev.length) && prev.length > chain.length) {
|
|
77
|
+
logger.error?.(`✗ policy anchor: el log retrocede (anchor previo=${prev.length} entradas, actual=${chain.length}) — truncado tras el último sello; NO se re-sella`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
} catch { /* sin anchor previo (o ilegible): primer sello */ }
|
|
81
|
+
const head = createHash("sha256").update(lines.at(-1), "utf8").digest("hex");
|
|
82
|
+
writeFileSync(anchorFile, `${JSON.stringify({ head, length: chain.length, ts: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
83
|
+
logger.info?.(`✓ policy anchor: cadena íntegra (${chain.length} decisiones) — head ${head.slice(0, 12)} sellado en .karajan/policy-anchor.json; committéalo para anclarlo en la historia de git`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GOV-B (KJC-TSK-0746): conceder una excepción PERMANENTE con el modelo
|
|
88
|
+
// probatorio completo — quién (identidad), regla exacta, justificación en
|
|
89
|
+
// el momento y caducidad obligatoria. Los defaults.* del consumidor son
|
|
90
|
+
// inexcepcionables: no se conceden ni desde aquí.
|
|
91
|
+
if (action === "grant") {
|
|
92
|
+
const { rule, until, reason } = flags;
|
|
93
|
+
if (!rule || !until || !reason?.trim()) {
|
|
94
|
+
logger.error?.("policy grant: --rule, --until (ISO) y --reason son obligatorios — una excepción sin quién/por qué/hasta cuándo no es una excepción, es un agujero");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
// Inexcepcionable = inexcepcionable TAMBIÉN al conceder (catch de codex):
|
|
98
|
+
// defaults.*, cualquier cap con class security, y una policy inválida
|
|
99
|
+
// (sin policy legible no se puede probar que la regla NO es security).
|
|
100
|
+
const m = /^roles\.([^.]+)\.(write|shell)/.exec(rule);
|
|
101
|
+
if (rule.startsWith("defaults.") || errors.length > 0 || (m && policy.roles?.[m[1]]?.[m[2]]?.class === "security")) {
|
|
102
|
+
logger.error?.(`policy grant: "${rule}" es inexcepcionable (default del proyecto o clase security) o la policy no es verificable — no se concede, ni con razón`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
// GOV-E (KJC-TSK-0750, idea del lector): la renovación es SEÑAL — una
|
|
107
|
+
// regla re-concedida N veces es la política real pidiendo que la
|
|
108
|
+
// cambien por su cauce. Se cuenta contra TODAS las permanentes previas
|
|
109
|
+
// (vencidas incluidas: precisamente esas son la sedimentación).
|
|
110
|
+
const previous = loadStandingExceptions(projectDir).standing.filter((e) => e.rule_id === rule).length;
|
|
111
|
+
const rec = recordPolicyException({
|
|
112
|
+
projectDir,
|
|
113
|
+
entry: { rule_id: rule, justification: reason.trim(), scopeKind: "permanente", expiresAt: until },
|
|
114
|
+
});
|
|
115
|
+
logger.info?.(`✓ excepción permanente registrada: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
|
|
116
|
+
if (previous >= 1) {
|
|
117
|
+
logger.warn?.(`⚠ ${previous + 1}ª concesión sobre esta regla — una excepción que se renueva ya no es una excepción: considera cambiar la política por su cauce (PR a .karajan/policy.yml)`);
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger.error?.(`policy grant: ${err.message}`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
41
126
|
if (action === "eval") {
|
|
42
127
|
let input;
|
|
43
128
|
try {
|
|
@@ -51,20 +136,29 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
51
136
|
return verdict.decision === "deny" && flags.strict ? 2 : 0;
|
|
52
137
|
}
|
|
53
138
|
|
|
54
|
-
// check —
|
|
55
|
-
|
|
139
|
+
// check — staged por defecto, base...head con --range (CI). Warn salvo
|
|
140
|
+
// --strict (PL-C): con --strict, una violación enforcement=deny devuelve
|
|
141
|
+
// exit 2 nombrando la regla — el contrato merge-blocking del tier C.
|
|
142
|
+
const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
|
|
56
143
|
const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
|
|
144
|
+
const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
|
|
57
145
|
if (flags.json) {
|
|
58
|
-
logger.info?.(JSON.stringify({ mode: "warn", violations }));
|
|
59
|
-
return 0;
|
|
146
|
+
logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
|
|
147
|
+
return hard.length > 0 ? 2 : 0;
|
|
60
148
|
}
|
|
61
149
|
if (violations.length === 0) {
|
|
62
150
|
logger.info?.("policy check: limpio");
|
|
63
151
|
return 0;
|
|
64
152
|
}
|
|
65
153
|
for (const v of violations) {
|
|
66
|
-
|
|
154
|
+
const where = v.file ? ` (${v.file})` : "";
|
|
155
|
+
const mark = flags.strict && v.enforcement === "deny" ? "✗" : "⚠";
|
|
156
|
+
logger.warn?.(`${mark} policy [${v.rule_id}] ${v.reason}${where}`);
|
|
157
|
+
}
|
|
158
|
+
if (hard.length > 0) {
|
|
159
|
+
logger.error?.(`policy check: ${hard.length} violación(es) con enforcement=deny — el merge no procede (--strict)`);
|
|
160
|
+
return 2;
|
|
67
161
|
}
|
|
68
|
-
logger.warn?.(`policy check: ${violations.length} aviso(s) — modo warn
|
|
162
|
+
logger.warn?.(`policy check: ${violations.length} aviso(s) — check es modo warn, no bloquea; los deny los aplican kj review --staged y el pre-commit (PL-B)`);
|
|
69
163
|
return 0;
|
|
70
164
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
|
-
import { checkVerdict } from "../review/verdict-store.js";
|
|
11
|
+
import { checkVerdict, diffHash } from "../review/verdict-store.js";
|
|
12
12
|
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";
|
|
@@ -16,6 +16,10 @@ import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js"
|
|
|
16
16
|
import { checkCardFirst } from "../review/card-first.js";
|
|
17
17
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
18
18
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
19
|
+
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
20
|
+
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
21
|
+
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
22
|
+
import { evaluatePolicyGate } from "../review/policy-gate.js";
|
|
19
23
|
|
|
20
24
|
// KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
|
|
21
25
|
// (before spending sonar/reviewer effort) AND on --check — the pre-commit
|
|
@@ -75,6 +79,28 @@ function printVerdict(record) {
|
|
|
75
79
|
export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
76
80
|
const projectDir = config?.projectDir || process.cwd();
|
|
77
81
|
const diff = await rawDiff(flags.range);
|
|
82
|
+
// KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
|
|
83
|
+
// arbitrable — solomon se niega antes de gastar un token. Y una policy
|
|
84
|
+
// inválida cierra el arbitraje en vez de abrirlo (fail closed, catch de
|
|
85
|
+
// codex: un `sec` vacío por error de carga NO puede significar "adelante").
|
|
86
|
+
const files = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
87
|
+
const pol = loadPolicy({ projectDir });
|
|
88
|
+
if (pol.errors.length > 0) {
|
|
89
|
+
for (const e of pol.errors) console.log(`✗ ${e}`);
|
|
90
|
+
console.log("⚖ Solomon no arbitra con policy.yml inválida — corrígela primero: una regla que miente es peor que ninguna.");
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return { ruling: "reject", reasoning: "invalid policy.yml — arbitration refused (fail closed)" };
|
|
93
|
+
}
|
|
94
|
+
const sec = checkStagedDiff(pol.policy, { files, netLinesAdded: 0 }).filter((v) => v.class === "security" && v.enforcement === "deny");
|
|
95
|
+
if (sec.length > 0) {
|
|
96
|
+
for (const v of sec) {
|
|
97
|
+
const where = v.file ? ` (${v.file})` : "";
|
|
98
|
+
console.log(`✗ policy [${v.rule_id}] ${v.reason}${where}`);
|
|
99
|
+
}
|
|
100
|
+
console.log("⚖ Solomon no arbitra hallazgos de clase seguridad — resuélvelos; la regla no es negociable.");
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
|
|
103
|
+
}
|
|
78
104
|
const res = await runSolomonArbitration({ diff, position: flags.position, config, logger, projectDir });
|
|
79
105
|
if (res.ruling === "approve") {
|
|
80
106
|
console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
|
|
@@ -122,7 +148,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
122
148
|
// Field case: a 522-line PR sailed past the warning with a
|
|
123
149
|
// rationalization — where the project wants teeth, pr_size: "block"
|
|
124
150
|
// rejects deterministically with a NAMED escape. Default stays warn.
|
|
125
|
-
|
|
151
|
+
// PL-C (KJC-TSK-0735), fuente única de umbrales: si la policy declara el
|
|
152
|
+
// invariante net_lines_added, SU max es el umbral — el mismo número que
|
|
153
|
+
// aplican kj policy check y el tier C de CI; method_gates queda de fallback.
|
|
154
|
+
const pol = loadPolicy({ projectDir });
|
|
155
|
+
const locInv = pol.errors.length === 0
|
|
156
|
+
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
157
|
+
: null;
|
|
158
|
+
const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_warn ?? 150;
|
|
159
|
+
const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
|
|
126
160
|
if (sizeWarn > 0) {
|
|
127
161
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
128
162
|
const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
|
|
@@ -131,12 +165,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
131
165
|
if (process.env.KJ_ALLOW_LARGE_PR === "1") {
|
|
132
166
|
console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
|
|
133
167
|
} else if (sizePolicy === "block") {
|
|
134
|
-
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
168
|
+
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
135
169
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
136
170
|
process.exitCode = 1;
|
|
137
171
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
138
172
|
} else {
|
|
139
|
-
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
173
|
+
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
140
174
|
}
|
|
141
175
|
}
|
|
142
176
|
}
|
|
@@ -187,6 +221,54 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
187
221
|
return { verdict: "rejected", reviewer: "tests-with-code", issues: [{ severity: "high", description: tests.reason }] };
|
|
188
222
|
}
|
|
189
223
|
|
|
224
|
+
// KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
|
|
225
|
+
// --staged Y en --check — el pre-commit hereda los dientes sin regenerar
|
|
226
|
+
// hooks. enforcement=warn avisa; deny cierra salvo excepción probatoria
|
|
227
|
+
// (KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, registrada con identidad y hash
|
|
228
|
+
// del diff); class=security cierra sin escape y sin arbitraje.
|
|
229
|
+
let net = 0;
|
|
230
|
+
for (const l of (await rawDiff(flags.range, ["--numstat"])).split("\n")) {
|
|
231
|
+
const [a, r] = l.trim().split(/\s+/);
|
|
232
|
+
if (a && a !== "-") net += Number(a) || 0;
|
|
233
|
+
if (r && r !== "-") net -= Number(r) || 0;
|
|
234
|
+
}
|
|
235
|
+
// GOV-B (KJC-TSK-0746): las permanentes concedidas (kj policy grant)
|
|
236
|
+
// eximen su regla mientras viven; el descarte de líneas corruptas se dice.
|
|
237
|
+
const std = loadStandingExceptions(projectDir);
|
|
238
|
+
if (std.discarded > 0) console.log(`⚠ policy: ${std.discarded} línea(s) corruptas descartadas en policy-exceptions.jsonl — revísalo`);
|
|
239
|
+
const gate = evaluatePolicyGate({
|
|
240
|
+
policy: pol.policy, errors: pol.errors, files: changedFiles, netLinesAdded: net,
|
|
241
|
+
diffHashValue: diffHash(diff),
|
|
242
|
+
recordException: (entry) => recordPolicyException({ projectDir, entry }),
|
|
243
|
+
standingExceptions: std.standing,
|
|
244
|
+
});
|
|
245
|
+
const at = (v) => (v.file ? ` (${v.file})` : "");
|
|
246
|
+
for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
|
|
247
|
+
for (const e of gate.exempted) {
|
|
248
|
+
console.log(e.standing
|
|
249
|
+
? `⚠ policy standing [${e.rule_id}] — excepción permanente viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
|
|
250
|
+
: `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
|
|
251
|
+
}
|
|
252
|
+
// GOV-C (KJC-TSK-0747): las decisiones de chokepoint dejan rastro
|
|
253
|
+
// hash-encadenado — cada deny, cada excepción, y el allow del commit.
|
|
254
|
+
const chokepoint = flags.check ? "commit" : "review";
|
|
255
|
+
const seal = (decision, extra = {}) =>
|
|
256
|
+
recordGateDecision(projectDir, { decision, chokepoint, policy_hash: policyFileHash(projectDir), artifact_hash: diffHash(diff), ...extra });
|
|
257
|
+
if (gate.exempted.length > 0) seal("exempt", { rule_ids: gate.exempted.map((e) => e.rule_id) });
|
|
258
|
+
if (!gate.ok) {
|
|
259
|
+
for (const d of gate.denials) {
|
|
260
|
+
const sec = d.class === "security" ? " [security — sin escape ni arbitraje]" : "";
|
|
261
|
+
console.log(`✗ policy [${d.rule_id}]${sec} ${d.reason}${at(d)}`);
|
|
262
|
+
}
|
|
263
|
+
seal("deny", { rule_ids: gate.denials.map((d) => d.rule_id) });
|
|
264
|
+
const reason = gate.invalid
|
|
265
|
+
? "policy.yml inválida — una regla que miente es peor que ninguna: corrígela"
|
|
266
|
+
: `${gate.denials.length} violación(es) de policy con enforcement=deny — el diff no entra`;
|
|
267
|
+
console.log(`✗ policy gate: ${reason}`);
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
return { verdict: "rejected", reviewer: "policy", issues: gate.denials.map((d) => ({ severity: "high", file: d.file, description: `[${d.rule_id}] ${d.reason}` })) };
|
|
270
|
+
}
|
|
271
|
+
|
|
190
272
|
if (flags.check) {
|
|
191
273
|
// KJC-BUG-0132 (issue #1344): a PURE merge commit stages no content of
|
|
192
274
|
// its own — everything it brings reached the parent branches already
|
|
@@ -206,6 +288,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
206
288
|
console.log(res.ok
|
|
207
289
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
208
290
|
: `✗ ${res.reason}`);
|
|
291
|
+
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
|
|
292
|
+
if (res.ok) seal("allow");
|
|
209
293
|
process.exitCode = res.ok ? 0 : 1;
|
|
210
294
|
return res;
|
|
211
295
|
}
|
|
@@ -88,7 +88,7 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
88
88
|
// a tool call (PostToolUse, always exit 0).
|
|
89
89
|
import { relative } from "node:path";
|
|
90
90
|
import { CODE, TESTS, ROOT, load, save, session } from "./sentinel-lib.mjs";
|
|
91
|
-
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII"];
|
|
91
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY"];
|
|
92
92
|
let raw = "";
|
|
93
93
|
process.stdin.on("data", (d) => { raw += d; });
|
|
94
94
|
process.stdin.on("end", () => {
|
|
@@ -329,6 +329,41 @@ process.stdin.on("end", () => {
|
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
|
+
// KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
|
|
333
|
+
// la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
|
|
334
|
+
// PL-A); los defaults del supervisor viven en el motor y el check
|
|
335
|
+
// PROTECTED inline de arriba queda como red fail-closed si kj falta.
|
|
336
|
+
// Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
|
|
337
|
+
// gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
|
|
338
|
+
// deny duro (el remedio es restaurar kj); cualquier otro fallo de
|
|
339
|
+
// evaluacion = deny con escape humano registrable — la sesion no queda
|
|
340
|
+
// presa de un typo de YAML, pero abrirla es decision del usuario.
|
|
341
|
+
if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
|
|
342
|
+
// PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
|
|
343
|
+
// kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
|
|
344
|
+
const actorRole = process.env.KJ_POLICY_ROLE || "coder";
|
|
345
|
+
const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
|
|
346
|
+
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.");
|
|
348
|
+
process.exit(2);
|
|
349
|
+
}
|
|
350
|
+
if (pres.status === 2) {
|
|
351
|
+
let v = null;
|
|
352
|
+
try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
|
|
353
|
+
const secure = !!(v && v.class === "security");
|
|
354
|
+
if (!secure && process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
355
|
+
else {
|
|
356
|
+
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
|
+
process.exit(2);
|
|
358
|
+
}
|
|
359
|
+
} else if (pres.status !== 0) {
|
|
360
|
+
if (process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
361
|
+
else {
|
|
362
|
+
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
|
+
process.exit(2);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
332
367
|
if (EDIT_TOOLS.includes(tool)) {
|
|
333
368
|
const file = input.file_path || input.notebook_path;
|
|
334
369
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
@@ -9,8 +9,29 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
|
|
12
15
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
13
|
-
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
16
|
+
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
|
+
|
|
18
|
+
// La versión del kj que corre harden — es la que se pinea en el fallback
|
|
19
|
+
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
20
|
+
// bundle SEA no hay package.json junto al módulo y una lectura eager a
|
|
21
|
+
// nivel de módulo tumbaba el binario entero al cargar (ENOENT /package.json).
|
|
22
|
+
let _ownVersion;
|
|
23
|
+
function ownVersion() {
|
|
24
|
+
if (_ownVersion === undefined) {
|
|
25
|
+
try {
|
|
26
|
+
_ownVersion = JSON.parse(
|
|
27
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"),
|
|
28
|
+
).version;
|
|
29
|
+
} catch {
|
|
30
|
+
_ownVersion = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return _ownVersion;
|
|
34
|
+
}
|
|
14
35
|
|
|
15
36
|
const BLOCK_VERSION = 1;
|
|
16
37
|
const WORKFLOWS_DIR = join(".github", "workflows");
|
|
@@ -50,13 +71,21 @@ export function installWorkflows({
|
|
|
50
71
|
profile = "standard",
|
|
51
72
|
mutation = false,
|
|
52
73
|
dryRun = false,
|
|
74
|
+
kjVersion = null,
|
|
53
75
|
} = {}) {
|
|
54
76
|
const dir = join(projectDir, WORKFLOWS_DIR);
|
|
55
77
|
const pm = detectPackageManager(projectDir);
|
|
56
78
|
const quality = qualityWorkflowFor(language, pm, hasLintScript(projectDir));
|
|
57
79
|
const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
|
|
58
80
|
const mut = mutation ? mutationWorkflowFor(language, pm) : null;
|
|
59
|
-
|
|
81
|
+
// PL-C: el tier C solo existe donde el proyecto DECLARA policy. Versión a
|
|
82
|
+
// pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
|
|
83
|
+
// recurso inalcanzable en los caminos reales.
|
|
84
|
+
const pinned = kjVersion ?? ownVersion() ?? "latest";
|
|
85
|
+
const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
|
|
86
|
+
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned) }
|
|
87
|
+
: null;
|
|
88
|
+
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
|
|
60
89
|
const results = [];
|
|
61
90
|
for (const wf of all) {
|
|
62
91
|
const target = join(dir, wf.file);
|
|
@@ -76,7 +105,10 @@ export function installWorkflows({
|
|
|
76
105
|
});
|
|
77
106
|
if (!dryRun && action !== "unchanged") {
|
|
78
107
|
mkdirSync(dir, { recursive: true });
|
|
79
|
-
|
|
108
|
+
// Newline final SIEMPRE: prettier (y POSIX) lo exigen y el bloque
|
|
109
|
+
// gestionado no lo garantiza — sin esto, el format-check del propio
|
|
110
|
+
// pre-commit rechaza el fichero recién generado.
|
|
111
|
+
writeFileSync(target, content.endsWith("\n") ? content : `${content}\n`);
|
|
80
112
|
}
|
|
81
113
|
results.push({ file: label, action });
|
|
82
114
|
}
|
|
@@ -54,6 +54,34 @@ export const WORKFLOWS = [
|
|
|
54
54
|
{ file: "kj-no-ai-attribution.yml", blockId: "wf-no-ai", body: NO_AI_ATTRIBUTION_WORKFLOW },
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
// PL-C (KJC-TSK-0735) — tier C del ADR 0001: re-check merge-blocking en CI
|
|
58
|
+
// (cubre el hook local manipulado). Solo se siembra con policy declarada;
|
|
59
|
+
// npx PINEADO a la versión del kj que corrió harden — jamás @latest en CI.
|
|
60
|
+
export const policyWorkflowFor = (kjVersion) => [
|
|
61
|
+
"name: Policy",
|
|
62
|
+
"on:",
|
|
63
|
+
" pull_request:",
|
|
64
|
+
"permissions:",
|
|
65
|
+
" contents: read",
|
|
66
|
+
"jobs:",
|
|
67
|
+
" policy-check:",
|
|
68
|
+
" runs-on: ubuntu-latest",
|
|
69
|
+
" steps:",
|
|
70
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
71
|
+
" with:",
|
|
72
|
+
" fetch-depth: 0",
|
|
73
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
74
|
+
" with:",
|
|
75
|
+
" node-version: 22",
|
|
76
|
+
" - name: kj policy check (strict) on the PR diff",
|
|
77
|
+
" env:",
|
|
78
|
+
" BASE_REF: ${{ github.base_ref }}",
|
|
79
|
+
" run: |",
|
|
80
|
+
` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes karajan-code@${kjVersion}"; fi`,
|
|
81
|
+
' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
|
|
82
|
+
"", // línea en blanco pre-marcador: prettier la exige tras un block scalar
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
57
85
|
const header = (steps) =>
|
|
58
86
|
[
|
|
59
87
|
"name: Quality",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj policy add "<regla hablada>"` (PL-C, KJC-TSK-0735) — el usuario habla,
|
|
3
|
+
* kj escribe: un agente traduce a UNA regla candidata del vocabulario
|
|
4
|
+
* CERRADO, parsePolicy valida el merge (lo intraducible se RECHAZA, jamás
|
|
5
|
+
* se inventa — el YAML es artefacto, no interfaz), se propone el diff y NO
|
|
6
|
+
* se aplica sin confirmación explícita (--yes).
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import yaml from "js-yaml";
|
|
11
|
+
import { parsePolicy } from "@karajan-family/governance";
|
|
12
|
+
import { createAgent } from "../agents/index.js";
|
|
13
|
+
import { resolveRole } from "../config/role-resolver.js";
|
|
14
|
+
|
|
15
|
+
const PROMPT = (text) => `Traduce esta regla de proyecto hablada a un FRAGMENTO YAML del vocabulario
|
|
16
|
+
cerrado v1 de policy de Karajan. Vocabulario COMPLETO (nada fuera de esto):
|
|
17
|
+
roles.<rol>.write|shell con allow/deny (listas de globs o patrones de
|
|
18
|
+
comando), enforcement (warn|deny), class (security); invariants con
|
|
19
|
+
{id, kind: diff-threshold, metric: net_lines_added, max, enforcement}.
|
|
20
|
+
Responde SOLO el fragmento YAML (claves raíz: roles y/o invariants), sin
|
|
21
|
+
explicación ni fences. Si la regla NO cabe en el vocabulario, responde
|
|
22
|
+
exactamente: NO_TRADUCIBLE.
|
|
23
|
+
|
|
24
|
+
Regla: ${text}`;
|
|
25
|
+
|
|
26
|
+
async function defaultTranslate({ text, config, logger }) {
|
|
27
|
+
const role = resolveRole(config, "reviewer");
|
|
28
|
+
const res = await createAgent(role.provider, config, logger).reviewTask({ prompt: PROMPT(text), role: "reviewer" });
|
|
29
|
+
if (!res?.ok) throw new Error(`el agente traductor (${role.provider}) falló: ${res?.error || "sin salida"}`);
|
|
30
|
+
return String(res.output || "").replace(/^```[a-z]*\n?|```\s*$/g, "").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge conservador: las listas se CONCATENAN (añadir jamás borra), los
|
|
34
|
+
// escalares nuevos solo entran donde no había valor.
|
|
35
|
+
function mergeFragment(doc, frag) {
|
|
36
|
+
const out = { version: 1, ...doc };
|
|
37
|
+
for (const [role, caps] of Object.entries(frag.roles || {})) {
|
|
38
|
+
out.roles ||= {};
|
|
39
|
+
out.roles[role] ||= {};
|
|
40
|
+
for (const [cap, spec] of Object.entries(caps || {})) {
|
|
41
|
+
const cur = (out.roles[role][cap] ||= {});
|
|
42
|
+
for (const [k, v] of Object.entries(spec || {})) {
|
|
43
|
+
cur[k] = Array.isArray(v) ? [...new Set([...(cur[k] || []), ...v])] : (cur[k] ?? v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (frag.invariants) out.invariants = [...(doc.invariants || []), ...frag.invariants];
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const naiveDiff = (oldText, newText) => {
|
|
52
|
+
const oldLines = new Set(oldText.split("\n"));
|
|
53
|
+
const newLines = new Set(newText.split("\n"));
|
|
54
|
+
return [
|
|
55
|
+
...[...oldLines].filter((l) => l.trim() && !newLines.has(l)).map((l) => `- ${l}`),
|
|
56
|
+
...[...newLines].filter((l) => l.trim() && !oldLines.has(l)).map((l) => `+ ${l}`),
|
|
57
|
+
].join("\n");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export async function policyAddCommand({ text, config = {}, flags = {}, logger = console, deps = {} }) {
|
|
61
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
62
|
+
if (!text?.trim()) {
|
|
63
|
+
logger.error?.('policy add: falta la regla — kj policy add "el coder no toca los workflows"');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const { translateFn = defaultTranslate } = deps;
|
|
67
|
+
const raw = await translateFn({ text, config, logger });
|
|
68
|
+
if (raw.includes("NO_TRADUCIBLE")) {
|
|
69
|
+
logger.error?.("policy add: la regla no cabe en el vocabulario cerrado v1 — no se inventa nada; exprésala como write/shell/diff-threshold o queda como instrucción de prompt");
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
let frag;
|
|
73
|
+
try {
|
|
74
|
+
frag = yaml.load(raw);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logger.error?.(`policy add: el agente no devolvió YAML válido (${err.message}) — nada que aplicar`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (typeof frag !== "object" || frag === null || Object.keys(frag).some((k) => !["roles", "invariants"].includes(k))) {
|
|
80
|
+
logger.error?.("policy add: el fragmento solo puede declarar roles y/o invariants — rechazado (vocabulario cerrado)");
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const file = join(projectDir, ".karajan", "policy.yml");
|
|
85
|
+
let current = "";
|
|
86
|
+
try {
|
|
87
|
+
current = readFileSync(file, "utf8");
|
|
88
|
+
} catch { /* sin policy previa: se propone crearla */ }
|
|
89
|
+
const merged = mergeFragment(current ? yaml.load(current) || {} : {}, frag);
|
|
90
|
+
const proposed = yaml.dump(merged, { lineWidth: 100 });
|
|
91
|
+
const { errors } = parsePolicy(proposed);
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
for (const e of errors) logger.error?.(`✗ ${e}`);
|
|
94
|
+
logger.error?.("policy add: la regla traducida no valida contra el vocabulario del motor — rechazada, no se inventa");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger.info?.(`policy add — diff propuesto para .karajan/policy.yml:\n${naiveDiff(current, proposed)}`);
|
|
99
|
+
if (current.includes("#")) {
|
|
100
|
+
logger.warn?.("⚠ policy add: el fichero actual tiene comentarios y el rewrite NO los preserva — revisa el diff antes de confirmar");
|
|
101
|
+
}
|
|
102
|
+
if (!flags.yes) {
|
|
103
|
+
logger.info?.("policy add: NADA aplicado — re-ejecuta con --yes para escribir (la confirmación es tuya, no del agente)");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
// Primer run sin .karajan/ (catch de codex): crear el padre, no reventar.
|
|
107
|
+
mkdirSync(join(projectDir, ".karajan"), { recursive: true });
|
|
108
|
+
writeFileSync(file, proposed, "utf8");
|
|
109
|
+
logger.info?.("✓ policy add: regla aplicada — los gates (review, pre-commit, tier C) la aplican desde ya");
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador del registro de decisiones para karajan-code (GOV-C,
|
|
3
|
+
* KJC-TSK-0747): almacén `.karajan/policy-decisions.jsonl`; policy_hash =
|
|
4
|
+
* sha256 del policy.yml CRUDO (null sin policy — la decisión lo dice, no
|
|
5
|
+
* lo inventa). Append no atómico entre procesos: aceptado, el chokepoint
|
|
6
|
+
* de commit es secuencial.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { recordDecision } from "@karajan-family/governance";
|
|
12
|
+
|
|
13
|
+
const filePath = (projectDir) => join(projectDir, ".karajan", "policy-decisions.jsonl");
|
|
14
|
+
|
|
15
|
+
export function policyFileHash(projectDir) {
|
|
16
|
+
try {
|
|
17
|
+
return createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "policy.yml"), "utf8"), "utf8").digest("hex");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {object} the sealed record. */
|
|
24
|
+
export function recordGateDecision(projectDir, entry) {
|
|
25
|
+
return recordDecision({
|
|
26
|
+
entry,
|
|
27
|
+
deps: {
|
|
28
|
+
lastLine: () => {
|
|
29
|
+
try {
|
|
30
|
+
const lines = readFileSync(filePath(projectDir), "utf8").split("\n").filter((l) => l.trim());
|
|
31
|
+
return lines.at(-1) ?? null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
append: (line) => {
|
|
37
|
+
mkdirSync(join(projectDir, ".karajan"), { recursive: true });
|
|
38
|
+
appendFileSync(filePath(projectDir), `${line}\n`, "utf8");
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
package/src/policy/engine.js
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* policy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* karajan-code policy ADAPTER (GOV-A, KJC-TSK-0745, ADR 0003). El motor
|
|
3
|
+
* vive en @karajan-family/governance (packages/governance): aquí queda SOLO lo
|
|
4
|
+
* que es de este dominio — dónde vive el fichero de policy, los defaults
|
|
5
|
+
* inexcepcionables del supervisor del Sentinel, y el mapeo de tools del
|
|
6
|
+
* harness (Write/Edit/Bash…) a capabilities del kernel. La superficie
|
|
7
|
+
* pública de este módulo no cambia: loadPolicy/evalToolCall/checkStagedDiff
|
|
8
|
+
* siguen siendo lo que los gates de PL-B consumen.
|
|
8
9
|
*/
|
|
9
10
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
ALLOW_VERDICT,
|
|
14
|
+
checkArtifacts,
|
|
15
|
+
denyVerdict,
|
|
16
|
+
evalShell,
|
|
17
|
+
evalWrite,
|
|
18
|
+
parsePolicy,
|
|
19
|
+
DEFAULT_POLICY as KERNEL_DEFAULT_POLICY,
|
|
20
|
+
} from "@karajan-family/governance";
|
|
13
21
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
22
|
+
// Defaults inexcepcionables de karajan-code: los ficheros del supervisor
|
|
23
|
+
// (antes hardcodeados en el PRETOOL del Sentinel — PL-B). Misma semántica
|
|
24
|
+
// textual: nombrarlos deniega, con o sin root, y la policy no los debilita.
|
|
25
|
+
export const SUPERVISOR_DEFAULTS = [
|
|
26
|
+
{
|
|
27
|
+
id: "defaults.supervisor.write",
|
|
28
|
+
pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\//,
|
|
29
|
+
message: "nombra ficheros del supervisor — solo el humano los modifica, fuera de la sesión",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Tools del harness: mapeo acción → capability del kernel. Registro cerrado
|
|
34
|
+
// (fallo de solomon: lo desconocido no se permite a un rol DECLARADO).
|
|
19
35
|
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
20
|
-
// Registro de tools conocidas (fallo de solomon: lo desconocido no se
|
|
21
|
-
// permite a un rol DECLARADO — crece por PR consciente).
|
|
22
36
|
const READONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]);
|
|
23
|
-
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_POLICY = KERNEL_DEFAULT_POLICY;
|
|
24
39
|
|
|
25
40
|
function defaultReadFile(projectDir) {
|
|
26
41
|
try {
|
|
@@ -34,7 +49,7 @@ function defaultReadFile(projectDir) {
|
|
|
34
49
|
|
|
35
50
|
/**
|
|
36
51
|
* @returns {{policy: object, errors: string[]}} errors non-empty ⇒ the file
|
|
37
|
-
* declares something
|
|
52
|
+
* declares something the engine cannot enforce — callers must surface it.
|
|
38
53
|
*/
|
|
39
54
|
export function loadPolicy({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
40
55
|
const { readFile = defaultReadFile } = deps;
|
|
@@ -42,207 +57,12 @@ export function loadPolicy({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
|
42
57
|
try {
|
|
43
58
|
raw = readFile(projectDir);
|
|
44
59
|
} catch (err) {
|
|
45
|
-
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const errors = [];
|
|
50
|
-
let doc;
|
|
51
|
-
try {
|
|
52
|
-
doc = yaml.load(raw) || {};
|
|
53
|
-
} catch (err) {
|
|
54
|
-
return { policy: DEFAULT_POLICY, errors: [`policy.yml: YAML invalido — ${err.message}`] };
|
|
55
|
-
}
|
|
56
|
-
if (typeof doc !== "object" || Array.isArray(doc)) {
|
|
57
|
-
return { policy: DEFAULT_POLICY, errors: ["policy.yml: la raiz debe ser un objeto"] };
|
|
58
|
-
}
|
|
59
|
-
if (doc.version !== 1) {
|
|
60
|
-
errors.push(`policy.yml: version "${doc.version}" no soportada — este motor habla version 1`);
|
|
61
|
-
}
|
|
62
|
-
const roles = doc.roles ?? {};
|
|
63
|
-
if (typeof roles !== "object" || roles === null || Array.isArray(roles)) {
|
|
64
|
-
errors.push("policy.yml: roles debe ser un objeto");
|
|
65
|
-
} else {
|
|
66
|
-
validateRoles(roles, errors);
|
|
67
|
-
}
|
|
68
|
-
const invs = doc.invariants ?? [];
|
|
69
|
-
if (Array.isArray(invs)) {
|
|
70
|
-
for (const inv of invs) {
|
|
71
|
-
if (!INVARIANT_KINDS.has(inv?.kind)) {
|
|
72
|
-
errors.push(`policy.yml: invariant "${inv?.id}" con kind "${inv?.kind}" no existe en el vocabulario v1`);
|
|
73
|
-
} else if (!INVARIANT_METRICS.has(inv?.metric) || !Number.isFinite(inv?.max) || typeof inv?.id !== "string") {
|
|
74
|
-
errors.push(`policy.yml: invariant "${inv?.id}" requiere id string, metric (${[...INVARIANT_METRICS].join(", ")}) y max numérico`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
} else {
|
|
78
|
-
errors.push("policy.yml: invariants debe ser una lista");
|
|
79
|
-
}
|
|
80
|
-
return { policy: { roles: {}, invariants: [], ...doc }, errors };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function validateRoles(roles, errors) {
|
|
84
|
-
for (const [role, caps] of Object.entries(roles)) {
|
|
85
|
-
for (const [cap, spec] of Object.entries(caps || {})) {
|
|
86
|
-
if (!ROLE_CAPS.has(cap)) {
|
|
87
|
-
errors.push(`policy.yml: roles.${role}.${cap} no existe en el vocabulario (v1: ${[...ROLE_CAPS].join(", ")})`);
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
|
|
91
|
-
errors.push(`policy.yml: roles.${role}.${cap} debe ser un objeto {allow, deny}`);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
for (const key of Object.keys(spec)) {
|
|
95
|
-
if (key !== "allow" && key !== "deny") {
|
|
96
|
-
errors.push(`policy.yml: roles.${role}.${cap}.${key} no existe en el vocabulario (v1: allow, deny)`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
for (const list of ["allow", "deny"]) {
|
|
100
|
-
const v = spec?.[list];
|
|
101
|
-
if (v !== undefined && (!Array.isArray(v) || v.some((g) => typeof g !== "string"))) {
|
|
102
|
-
errors.push(`policy.yml: roles.${role}.${cap}.${list} debe ser una lista de strings (glob)`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const deny = (rule_id, reason) => ({ decision: "deny", rule_id, reason });
|
|
110
|
-
const ALLOW = Object.freeze({ decision: "allow" });
|
|
111
|
-
|
|
112
|
-
// Normaliza el destino a ruta RELATIVA canónica: absolutas se relativizan
|
|
113
|
-
// contra root (sin root ⇒ null), y `..`/escapes ⇒ null (reviewer catch:
|
|
114
|
-
// src/../.env no puede esquivar los globs, ni una absoluta salirse del root).
|
|
115
|
-
function normalizeTarget(filePath, root) {
|
|
116
|
-
let p = String(filePath).replaceAll("\\", "/");
|
|
117
|
-
if (isAbsolute(p)) {
|
|
118
|
-
if (!root) return null;
|
|
119
|
-
p = relative(root, p).replaceAll("\\", "/");
|
|
60
|
+
return {
|
|
61
|
+
policy: { ...KERNEL_DEFAULT_POLICY, defaults: SUPERVISOR_DEFAULTS },
|
|
62
|
+
errors: [`policy.yml: ilegible (${err.code || err.message}) — presente pero no leible NO es "sin policy"`],
|
|
63
|
+
};
|
|
120
64
|
}
|
|
121
|
-
|
|
122
|
-
return p.startsWith("..") || isAbsolute(p) ? null : p;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function evalWrite(policy, role, filePath, root) {
|
|
126
|
-
const write = policy.roles?.[role]?.write;
|
|
127
|
-
if (!write) return ALLOW;
|
|
128
|
-
// Rol restringido + destino ausente/no-canonizable = no verificable ⇒ deny
|
|
129
|
-
// (reviewer catches: tool call malformado, traversal, absoluta sin root).
|
|
130
|
-
const p = filePath ? normalizeTarget(filePath, root) : null;
|
|
131
|
-
if (p == null) return deny(`roles.${role}.write`, `destino "${filePath ?? ""}" no verificable contra la policy del rol ${role}`);
|
|
132
|
-
if (matchesAny(p, write.deny)) {
|
|
133
|
-
return deny(`roles.${role}.write.deny`, `${p} esta denegado para el rol ${role}`);
|
|
134
|
-
}
|
|
135
|
-
if (Array.isArray(write.allow) && !matchesAny(p, write.allow)) {
|
|
136
|
-
return deny(`roles.${role}.write.allow`, `${p} esta fuera de la allow-list de escritura del rol ${role}`);
|
|
137
|
-
}
|
|
138
|
-
return ALLOW;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Launchers y asignaciones de entorno se PELAN antes de casar patrones
|
|
142
|
-
// (reviewer catch: "sudo git push" o "FOO=1 git add -A" no esquivan la
|
|
143
|
-
// regla del subcomando real); capas excesivas ⇒ opaco ⇒ deny.
|
|
144
|
-
const LAUNCHER_RE = /^(sudo(\s+-\S+)*|command|nohup|time|nice(\s+-n\s*\d+)?|stdbuf\s+\S+|env)\s+|^([A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/;
|
|
145
|
-
function stripLaunchers(seg) {
|
|
146
|
-
let s = seg;
|
|
147
|
-
for (let i = 0; i < 5; i++) {
|
|
148
|
-
const m = s.match(LAUNCHER_RE);
|
|
149
|
-
if (!m) return s;
|
|
150
|
-
// Asignaciones que alteran QUÉ ejecutable resuelve (PATH, LD_*, GIT_*,
|
|
151
|
-
// NODE_OPTIONS) o llevan expansión son opacas — no se pelan, denegan.
|
|
152
|
-
if (/^(PATH|LD_\w*|NODE_OPTIONS|GIT_\w*)=/.test(m[0]) || m[0].includes("$")) return null;
|
|
153
|
-
s = s.slice(m[0].length);
|
|
154
|
-
}
|
|
155
|
-
return null; // sigue envuelto tras 5 capas: no verificable
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Tokenizador consciente de comillas: separa segmentos SOLO por operadores
|
|
159
|
-
// no entrecomillados (reviewer catch: un ';' dentro de un mensaje no es
|
|
160
|
-
// separador) y canonicaliza tokens sin comillas a la vez. Cualquier
|
|
161
|
-
// construcción que ejecute o redirija texto opaco al análisis — escapes,
|
|
162
|
-
// expansión ($ salvo entre comillas simples), sustitución/backticks,
|
|
163
|
-
// redirecciones, backgrounding, comillas sin cerrar — devuelve null.
|
|
164
|
-
function parseCommand(cmd) {
|
|
165
|
-
const segs = [];
|
|
166
|
-
let toks = [];
|
|
167
|
-
let cur = "";
|
|
168
|
-
let q = null;
|
|
169
|
-
const pushTok = () => {
|
|
170
|
-
if (cur) {
|
|
171
|
-
toks.push(cur);
|
|
172
|
-
cur = "";
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
const pushSeg = () => {
|
|
176
|
-
pushTok();
|
|
177
|
-
if (toks.length > 0) {
|
|
178
|
-
segs.push(toks.join(" "));
|
|
179
|
-
toks = [];
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
const s = String(cmd);
|
|
183
|
-
for (let i = 0; i < s.length; i++) {
|
|
184
|
-
const ch = s[i];
|
|
185
|
-
if (ch === "\\" || ch === "`") return null;
|
|
186
|
-
if (ch === "$" && q !== "'") return null;
|
|
187
|
-
if (q) {
|
|
188
|
-
if (ch === q) q = null;
|
|
189
|
-
else cur += ch;
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (ch === '"' || ch === "'") {
|
|
193
|
-
q = ch;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (ch === ">" || ch === "<") return null;
|
|
197
|
-
if (ch === "&" && s[i + 1] === "&") {
|
|
198
|
-
pushSeg();
|
|
199
|
-
i++;
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
if (ch === "|" && s[i + 1] === "|") {
|
|
203
|
-
pushSeg();
|
|
204
|
-
i++;
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (ch === "&") return null;
|
|
208
|
-
if (ch === ";" || ch === "|" || ch === "\n") {
|
|
209
|
-
pushSeg();
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (/\s/.test(ch)) {
|
|
213
|
-
pushTok();
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
cur += ch;
|
|
217
|
-
}
|
|
218
|
-
if (q) return null;
|
|
219
|
-
pushSeg();
|
|
220
|
-
return segs;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Envolturas que re-ejecutan texto (lección del guard del Sentinel).
|
|
224
|
-
const WRAPPER_RE = /^(sh|bash|zsh|dash|ksh)\s+(-\S+\s+)*-\S*c(\s|$)|^eval\b|^xargs\b|^source\b|^\.\s/;
|
|
225
|
-
|
|
226
|
-
// Deny si CUALQUIER segmento casa; con allow-list, CADA segmento debe casar.
|
|
227
|
-
function evalShell(policy, role, command) {
|
|
228
|
-
const shell = policy.roles?.[role]?.shell;
|
|
229
|
-
if (!shell) return ALLOW;
|
|
230
|
-
if (!command) return deny(`roles.${role}.shell`, `comando ausente — no verificable para el rol ${role}`);
|
|
231
|
-
const segs = parseCommand(command);
|
|
232
|
-
if (segs == null) {
|
|
233
|
-
return deny(`roles.${role}.shell`, "construcciones opacas (escapes, expansión, sustitución, redirecciones, backgrounding o comillas sin cerrar) — no verificable");
|
|
234
|
-
}
|
|
235
|
-
for (const raw of segs) {
|
|
236
|
-
const seg = stripLaunchers(raw);
|
|
237
|
-
if (seg == null) return deny(`roles.${role}.shell`, `el segmento "${raw}" no es canonicalizable — no verificable`);
|
|
238
|
-
if (WRAPPER_RE.test(seg)) return deny(`roles.${role}.shell`, `el segmento "${seg}" re-ejecuta texto (envoltura/eval) — no verificable`);
|
|
239
|
-
const hit = Array.isArray(shell.deny) && shell.deny.find((p) => commandPatternToRegExp(p).test(seg));
|
|
240
|
-
if (hit) return deny(`roles.${role}.shell.deny`, `el segmento "${seg}" casa con el patrón denegado "${hit}"`);
|
|
241
|
-
if (Array.isArray(shell.allow) && !shell.allow.some((p) => commandPatternToRegExp(p).test(seg))) {
|
|
242
|
-
return deny(`roles.${role}.shell.allow`, `el segmento "${seg}" está fuera de la allow-list de shell del rol ${role}`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return ALLOW;
|
|
65
|
+
return parsePolicy(raw, { defaults: SUPERVISOR_DEFAULTS });
|
|
246
66
|
}
|
|
247
67
|
|
|
248
68
|
/** One tool call against the policy. `root` permite evaluar file_path
|
|
@@ -251,26 +71,12 @@ function evalShell(policy, role, command) {
|
|
|
251
71
|
export function evalToolCall(policy, { role = "coder", tool, input = {}, root = null }) {
|
|
252
72
|
if (WRITE_TOOLS.has(tool)) return evalWrite(policy, role, input.file_path || input.notebook_path, root);
|
|
253
73
|
if (tool === "Bash") return evalShell(policy, role, input.command);
|
|
254
|
-
if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return
|
|
255
|
-
return
|
|
74
|
+
if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW_VERDICT;
|
|
75
|
+
return denyVerdict(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
|
|
256
76
|
}
|
|
257
77
|
|
|
258
|
-
/** El gate del RESULTADO
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const r = evalWrite(policy, role, f);
|
|
263
|
-
if (r.decision === "deny") violations.push({ rule_id: r.rule_id, reason: r.reason, file: f });
|
|
264
|
-
}
|
|
265
|
-
for (const inv of policy.invariants || []) {
|
|
266
|
-
if (inv.kind !== "diff-threshold" || inv.metric !== "net_lines_added") continue;
|
|
267
|
-
if (!Number.isFinite(netLinesAdded)) {
|
|
268
|
-
// Métrica ausente con invariante declarado = no verificable, jamás un
|
|
269
|
-
// pase silencioso (reviewer catch).
|
|
270
|
-
violations.push({ rule_id: inv.id, reason: "net_lines_added no disponible — el invariante no es verificable sin la métrica" });
|
|
271
|
-
} else if (netLinesAdded > inv.max) {
|
|
272
|
-
violations.push({ rule_id: inv.id, reason: `net_lines_added=${netLinesAdded} supera el máximo ${inv.max}` });
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return violations;
|
|
78
|
+
/** El gate del RESULTADO en este dominio: los artefactos son los ficheros
|
|
79
|
+
* del diff staged y la métrica sale de numstat. */
|
|
80
|
+
export function checkStagedDiff(policy, facts) {
|
|
81
|
+
return checkArtifacts(policy, facts);
|
|
276
82
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador de excepciones de karajan-code (GOV-A, KJC-TSK-0745). El
|
|
3
|
+
* registro vive en el kernel (@karajan-family/governance); aquí solo lo que es de
|
|
4
|
+
* este dominio: la identidad (git user + usuario del SO — DECLARADA, no
|
|
5
|
+
* autenticada) y el destino append-only `.karajan/policy-exceptions.jsonl`.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { userInfo } from "node:os";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { recordPolicyException as kernelRecord } from "@karajan-family/governance";
|
|
12
|
+
|
|
13
|
+
function defaultIdentity(projectDir) {
|
|
14
|
+
const git = (args) => spawnSync("git", ["-C", projectDir, "config", ...args], { encoding: "utf8" }).stdout?.trim() || null;
|
|
15
|
+
// grade DECLARADA: git+os es atribución, no autenticación — el registro
|
|
16
|
+
// no debe venderse como más evidencia de la que es (GOV-B).
|
|
17
|
+
return { git: `${git(["user.name"]) ?? "?"} <${git(["user.email"]) ?? "?"}>`, os: userInfo().username, grade: "declarada" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Standing exceptions del dominio code: las permanentes del jsonl. Parse
|
|
22
|
+
* TOLERANTE — una línea corrupta se descarta CONTÁNDOLA (el gate la
|
|
23
|
+
* reporta), jamás rompe la evaluación. Las puntuales no dan standing.
|
|
24
|
+
* @returns {{standing: object[], discarded: number}}
|
|
25
|
+
*/
|
|
26
|
+
export function loadStandingExceptions(projectDir) {
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = readFileSync(join(projectDir, ".karajan", "policy-exceptions.jsonl"), "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return { standing: [], discarded: 0 };
|
|
32
|
+
}
|
|
33
|
+
const standing = [];
|
|
34
|
+
let discarded = 0;
|
|
35
|
+
for (const line of raw.split("\n")) {
|
|
36
|
+
if (!line.trim()) continue;
|
|
37
|
+
try {
|
|
38
|
+
const rec = JSON.parse(line);
|
|
39
|
+
// JSON válido pero no-objeto (null, número…) es tan corrupto como el
|
|
40
|
+
// que no parsea: se descarta contando (catch de codex, explícito).
|
|
41
|
+
if (typeof rec !== "object" || rec === null) discarded += 1;
|
|
42
|
+
else if (rec.scopeKind === "permanente") standing.push(rec);
|
|
43
|
+
} catch {
|
|
44
|
+
discarded += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { standing, discarded };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultAppend(projectDir, line) {
|
|
51
|
+
const dir = join(projectDir, ".karajan");
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @returns {object} the recorded entry (with ts + who resolved). */
|
|
57
|
+
export function recordPolicyException({ projectDir, entry, deps = {} }) {
|
|
58
|
+
const { append = defaultAppend, identity = defaultIdentity } = deps;
|
|
59
|
+
return kernelRecord({ projectDir, entry, deps: { append, identity } });
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador del gate de policy en el flujo de review de karajan-code
|
|
3
|
+
* (GOV-A, KJC-TSK-0745). La decisión warn/deny/inexcepcionable/excepción
|
|
4
|
+
* vive en el kernel (@karajan-family/governance); aquí solo el dominio: la
|
|
5
|
+
* excepción se pide con KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, y el
|
|
6
|
+
* artefacto de este dominio es el diff staged — por eso el alcance
|
|
7
|
+
* registrado dice "este diff exacto".
|
|
8
|
+
*/
|
|
9
|
+
import { evaluateGate } from "@karajan-family/governance";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @returns {{ok: boolean, invalid?: boolean, warns: object[], denials: object[], exempted: object[]}}
|
|
13
|
+
*/
|
|
14
|
+
export function evaluatePolicyGate({
|
|
15
|
+
policy, errors = [], role = "coder", files = [], netLinesAdded = null,
|
|
16
|
+
diffHashValue = null, env = process.env, recordException = () => {},
|
|
17
|
+
standingExceptions = [], now = new Date(),
|
|
18
|
+
}) {
|
|
19
|
+
return evaluateGate({
|
|
20
|
+
policy, errors, role, files, netLinesAdded,
|
|
21
|
+
artifactHash: diffHashValue,
|
|
22
|
+
exemption: { requested: env.KJ_ALLOW_POLICY === "1", justification: env.KJ_POLICY_REASON, hint: 'KJ_POLICY_REASON="por qué"' },
|
|
23
|
+
recordException: (entry) => recordException({ ...entry, scope: "este diff exacto" }),
|
|
24
|
+
standingExceptions, now,
|
|
25
|
+
});
|
|
26
|
+
}
|
package/src/policy/glob.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// glob → RegExp anclada — primitivo de la policy layer (PL-A, KJC-TSK-0733).
|
|
2
|
-
// Semántica: doble asterisco cruza directorios; doble asterisco + barra es
|
|
3
|
-
// prefijo de directorio OPCIONAL (así "**" "/*.env*" casa también con .env
|
|
4
|
-
// en la raíz); "*" no sale de su segmento; "?" es un carácter. Sin
|
|
5
|
-
// dependencias: el vocabulario es pequeño a propósito — una lib general
|
|
6
|
-
// traería semánticas que la policy no declara.
|
|
7
|
-
export function globToRegExp(glob) {
|
|
8
|
-
let re = "";
|
|
9
|
-
const s = String(glob);
|
|
10
|
-
let i = 0;
|
|
11
|
-
while (i < s.length) {
|
|
12
|
-
const c = s[i];
|
|
13
|
-
if (c === "*" && s[i + 1] === "*" && s[i + 2] === "/") {
|
|
14
|
-
re += "(?:.*/)?";
|
|
15
|
-
i += 3;
|
|
16
|
-
} else if (c === "*" && s[i + 1] === "*") {
|
|
17
|
-
re += ".*";
|
|
18
|
-
i += 2;
|
|
19
|
-
} else if (c === "*") {
|
|
20
|
-
re += "[^/]*";
|
|
21
|
-
i += 1;
|
|
22
|
-
} else if (c === "?") {
|
|
23
|
-
re += "[^/]";
|
|
24
|
-
i += 1;
|
|
25
|
-
} else {
|
|
26
|
-
re += c.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`);
|
|
27
|
-
i += 1;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return new RegExp(`^${re}$`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** true si `value` casa con ALGUNO de los globs (lista no-array ⇒ false). */
|
|
34
|
-
export const matchesAny = (value, globs) =>
|
|
35
|
-
Array.isArray(globs) && globs.some((g) => globToRegExp(g).test(value));
|
|
36
|
-
|
|
37
|
-
/** Patrón de comando shell → RegExp anclada al INICIO: `*` = cualquier cosa
|
|
38
|
-
* (los comandos no son rutas — sin semántica de segmentos). */
|
|
39
|
-
export function commandPatternToRegExp(pattern) {
|
|
40
|
-
const re = String(pattern)
|
|
41
|
-
.replaceAll(/[.+^${}()|[\]\\?]/g, String.raw`\$&`)
|
|
42
|
-
.replaceAll("*", ".*");
|
|
43
|
-
return new RegExp(`^${re}`);
|
|
44
|
-
}
|