karajan-code 4.17.0 → 4.19.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/scripts/dual-publish.mjs +67 -0
- package/scripts/verify-pack.mjs +8 -5
- package/src/agents/claude-agent.js +7 -2
- package/src/checks/guarantee-level.js +38 -0
- package/src/cli/register-meta.js +33 -2
- 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/policy.js +103 -9
- package/src/commands/review-gate.js +111 -4
- package/src/environment/briefs.js +3 -0
- package/src/environment/playbook.js +3 -1
- package/src/harden/sentinel-hooks.js +92 -2
- package/src/harden/workflow-engine.js +35 -3
- package/src/harden/workflow-templates.js +28 -0
- package/src/mutate/diff-scope.js +6 -2
- 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/mutation-pregate.js +48 -0
- package/src/review/policy-gate.js +26 -0
- package/src/start/sweep.js +46 -4
- package/src/utils/update-check.js +25 -6
- package/src/policy/glob.js +0 -44
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,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
|
+
}
|
|
@@ -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/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)),
|
|
@@ -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);
|
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
|
-
}
|