karajan-code 4.21.0 → 4.23.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 +6 -3
- package/package.json +5 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/auth.js +29 -1
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/audit/basal-cost.js +4 -0
- package/src/audit/dead-exports.js +52 -1
- package/src/audit/deterministic-summary.js +18 -3
- package/src/audit/member-reachability.js +158 -0
- package/src/audit/osv-findings.js +1 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +86 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +66 -5
- package/src/commands/claims.js +73 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +34 -9
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +188 -22
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/privacy/scan.js +23 -1
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- package/src/review/unparseable-verdict.js +50 -0
- package/src/roles/audit-role.js +19 -1
- package/src/sonar/scanner.js +34 -8
- package/src/steward/invariants.js +190 -0
- package/src/steward/phantom-coverage.js +137 -0
- package/src/steward/proposed-work.js +68 -0
|
@@ -52,7 +52,8 @@ const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
|
|
|
52
52
|
|
|
53
53
|
You are the orchestrator; Karajan governs. A task is DONE when its
|
|
54
54
|
done-statement is literally true, the full suite is green, and every commit
|
|
55
|
-
carries a cross-AI verdict.
|
|
55
|
+
carries a cross-AI verdict. A card closed? Point your user at the board
|
|
56
|
+
(\`kj board start\` → http://localhost:4000/#governance): that is where to look.
|
|
56
57
|
|
|
57
58
|
Invariants (the git gates enforce these — they are not suggestions):
|
|
58
59
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate class members (KJC-TSK-0796) — a bug JavaScript refuses to report.
|
|
3
|
+
*
|
|
4
|
+
* Declaring the same member twice in a class is legal: the LAST one wins and the
|
|
5
|
+
* first disappears in silence. No runtime error, no lint warning, no compiler
|
|
6
|
+
* complaint. That is how a `updated()` that loaded the data was lost in GREBLA
|
|
7
|
+
* and a tab stayed empty in production for 17 days.
|
|
8
|
+
*
|
|
9
|
+
* What matters is not that there is a duplicate — it is WHAT WAS LOST: the body
|
|
10
|
+
* that will never run. That is what the finding says.
|
|
11
|
+
*
|
|
12
|
+
* Honest about its limits (the fourth verdict): a file this guard could not read,
|
|
13
|
+
* or one whose member names are computed at runtime, is reported as NOT OBSERVABLE
|
|
14
|
+
* — never as clean.
|
|
15
|
+
*/
|
|
16
|
+
import { parse } from "@babel/parser";
|
|
17
|
+
|
|
18
|
+
const plugins = (file) => [
|
|
19
|
+
...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
|
|
20
|
+
...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
|
|
21
|
+
"decorators",
|
|
22
|
+
];
|
|
23
|
+
const isClass = (node) => node?.type === "ClassDeclaration" || node?.type === "ClassExpression";
|
|
24
|
+
|
|
25
|
+
/** Walks every node of the tree; enough for a syntax-only guard (no scope analysis). */
|
|
26
|
+
function walk(node, visit) {
|
|
27
|
+
if (!node || typeof node.type !== "string") return;
|
|
28
|
+
visit(node);
|
|
29
|
+
for (const key of Object.keys(node)) {
|
|
30
|
+
const child = node[key];
|
|
31
|
+
if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
|
|
32
|
+
else if (child && typeof child.type === "string") walk(child, visit);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const memberName = (member) => {
|
|
37
|
+
if (member.computed) return null; // `[expr]() {}` — the name is not known until it runs
|
|
38
|
+
const key = member.key ?? member.id;
|
|
39
|
+
if (key?.type === "Identifier") return key.name;
|
|
40
|
+
if (key?.type === "PrivateName") return `#${key.id.name}`;
|
|
41
|
+
if (key?.type === "StringLiteral" || key?.type === "NumericLiteral") return String(key.value);
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} source
|
|
47
|
+
* @param {{file?: string}} [where]
|
|
48
|
+
* @returns {{findings: Array<object>, notObservable: {file: string, reason: string}|null}}
|
|
49
|
+
* Each finding: {file, className, member, lostLine, winnerLine, message}.
|
|
50
|
+
*/
|
|
51
|
+
export function findDuplicateMembers(source, { file = "<source>" } = {}) {
|
|
52
|
+
let tree;
|
|
53
|
+
try {
|
|
54
|
+
tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { findings: [], notObservable: { file, reason: `could not be parsed (${err.message}) — not read as clean` } };
|
|
57
|
+
}
|
|
58
|
+
const findings = [];
|
|
59
|
+
let computed = 0;
|
|
60
|
+
walk(tree, (node) => {
|
|
61
|
+
if (!isClass(node)) return;
|
|
62
|
+
const className = node.id?.name ?? "(anonymous)";
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
for (const member of node.body.body) {
|
|
65
|
+
// get/set of the same name are legal, and a static member is a different slot from an
|
|
66
|
+
// instance one — treating either as a duplicate would be the false positive that gets a
|
|
67
|
+
// guard switched off (the credibility rule of KJC-PCS-0082).
|
|
68
|
+
// A TypeScript overload signature (TSDeclareMethod) declares the same name on purpose and
|
|
69
|
+
// carries no body: counting it would flag every overloaded method in the codebase.
|
|
70
|
+
if (member.type === "StaticBlock" || member.type === "TSDeclareMethod" || member.type === "TSIndexSignature" || member.kind === "get" || member.kind === "set") continue;
|
|
71
|
+
const name = memberName(member);
|
|
72
|
+
if (name === null) { computed += 1; continue; }
|
|
73
|
+
const key = `${member.static ? "static " : ""}${name}`;
|
|
74
|
+
const line = member.loc.start.line;
|
|
75
|
+
const first = seen.get(key);
|
|
76
|
+
if (first === undefined) { seen.set(key, line); continue; }
|
|
77
|
+
findings.push({
|
|
78
|
+
file, className, member: key, lostLine: first, winnerLine: line,
|
|
79
|
+
message: `${file}:${line} — ${className}: the ${key} of line ${first} never runs, the one of line ${line} replaces it`,
|
|
80
|
+
});
|
|
81
|
+
seen.set(key, line); // a third copy is reported against the second, not against the first
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const notObservable = computed > 0 && findings.length === 0 ? { file, reason: `${computed} member name(s) computed at runtime — this file cannot be fully checked` } : null;
|
|
85
|
+
return { findings, notObservable };
|
|
86
|
+
}
|
|
@@ -29,7 +29,7 @@ const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj h
|
|
|
29
29
|
// Single source for every sentinel script: state, branch, classification,
|
|
30
30
|
// violations, and escape recording.
|
|
31
31
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { execSync } from "node:child_process";
|
|
32
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
33
33
|
import { dirname, join } from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -75,9 +75,24 @@ export const violations = (s, branch) => {
|
|
|
75
75
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
76
76
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
77
77
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
78
|
-
if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
78
|
+
if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
79
79
|
return v;
|
|
80
80
|
};
|
|
81
|
+
// KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
|
|
82
|
+
// source edits only REMOVED lines owes no test. Unreadable git = conservative
|
|
83
|
+
// (the violation stands): a gate must never stand down on a shrug.
|
|
84
|
+
const sessionAddsCode = (s) => {
|
|
85
|
+
const files = s.edited_sources || [];
|
|
86
|
+
// _git builds a shell string: any path outside a strict charset stays out of
|
|
87
|
+
// it — and the gate stays ARMED for it (conservative, and injection-proof).
|
|
88
|
+
if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
|
|
89
|
+
const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
|
|
90
|
+
if (!stat) return true;
|
|
91
|
+
return stat.split(String.fromCharCode(10)).some((l) => {
|
|
92
|
+
const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
|
|
93
|
+
return a === "-" || Number(a) > 0;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
81
96
|
export const recordEscape = (sid, escape, tool) => {
|
|
82
97
|
const state = load();
|
|
83
98
|
const s = session(state, sid);
|
|
@@ -85,6 +100,18 @@ export const recordEscape = (sid, escape, tool) => {
|
|
|
85
100
|
if (!s.escapes.includes(escape)) s.escapes.push(escape);
|
|
86
101
|
(state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
|
|
87
102
|
save(state);
|
|
103
|
+
// GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
|
|
104
|
+
// decision log hash-encadenado (kj policy seal), no solo en este estado.
|
|
105
|
+
// Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
|
|
106
|
+
// kj no carga, que es cuando mas se usa el escape (restaurar con git).
|
|
107
|
+
try {
|
|
108
|
+
const args = ["policy", "seal", "--escape", escape];
|
|
109
|
+
if (tool) args.push("--tool", String(tool));
|
|
110
|
+
const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
|
|
111
|
+
if (r.error || r.status !== 0) console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json");
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message);
|
|
114
|
+
}
|
|
88
115
|
};
|
|
89
116
|
`;
|
|
90
117
|
|
|
@@ -112,9 +139,11 @@ process.stdin.on("end", () => {
|
|
|
112
139
|
const clearPending = (cardId) => {
|
|
113
140
|
const state = load();
|
|
114
141
|
const s = session(state, sid);
|
|
115
|
-
const before = (s.pending_moves || []).length;
|
|
116
142
|
s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
|
|
117
|
-
|
|
143
|
+
// KJC-TSK-0774: a card closed this turn is what the Stop hook points the
|
|
144
|
+
// user at when the focus comes back (the board is where to look).
|
|
145
|
+
if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
|
|
146
|
+
save(state);
|
|
118
147
|
};
|
|
119
148
|
if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
120
149
|
// The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
|
|
@@ -122,7 +151,21 @@ process.stdin.on("end", () => {
|
|
|
122
151
|
// the first real update_card did not clear its pending entry).
|
|
123
152
|
const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
|
|
124
153
|
const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
|
|
125
|
-
if (cid && st && CLOSING.includes(st[1].toLowerCase()))
|
|
154
|
+
if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
|
|
155
|
+
clearPending(cid[1].toUpperCase());
|
|
156
|
+
// KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
|
|
157
|
+
// before the tracker confirmed the create) could not be cleared by any
|
|
158
|
+
// verified route. The pending's TRUE identity is its PR: a CONFIRMED
|
|
159
|
+
// closing update whose request names that PR clears it too — still a
|
|
160
|
+
// real tracker call seen by this hook, never a promise in prose.
|
|
161
|
+
const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
|
|
162
|
+
if (Number.isFinite(prIn) && prIn > 0) {
|
|
163
|
+
const state = load();
|
|
164
|
+
const s = session(state, sid);
|
|
165
|
+
s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
|
|
166
|
+
save(state);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
126
169
|
process.exit(0);
|
|
127
170
|
}
|
|
128
171
|
if (tool === "Bash") {
|
|
@@ -133,20 +176,30 @@ process.stdin.on("end", () => {
|
|
|
133
176
|
const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
|
|
134
177
|
const said = /merged pull request #(\\d+)/i.exec(text);
|
|
135
178
|
let mergedPr = said ? Number(said[1]) : null;
|
|
136
|
-
|
|
137
|
-
|
|
179
|
+
// KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
|
|
180
|
+
// branch the working tree happens to be on (found live: a merge issued
|
|
181
|
+
// from another card's lane pinned the pending move on the wrong card).
|
|
182
|
+
let headRef = null;
|
|
183
|
+
if (mergeCmd || said) {
|
|
184
|
+
const n = said ? said[1] : mergeCmd[2];
|
|
185
|
+
const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
|
|
138
186
|
const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
|
|
139
|
-
try {
|
|
187
|
+
try {
|
|
188
|
+
const v = JSON.parse(r.status === 0 ? r.stdout : "null");
|
|
189
|
+
if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
|
|
190
|
+
} catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
|
|
140
191
|
}
|
|
141
192
|
if (mergedPr !== null) {
|
|
142
193
|
const state = load();
|
|
143
194
|
const s = session(state, sid);
|
|
144
|
-
const ref = CARD.exec(branchOf() || "");
|
|
145
|
-
(s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now() });
|
|
195
|
+
const ref = CARD.exec(headRef || branchOf() || "");
|
|
196
|
+
(s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
|
|
146
197
|
save(state);
|
|
147
198
|
}
|
|
148
199
|
const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
|
|
149
|
-
|
|
200
|
+
// "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
|
|
201
|
+
// clear a pending — that would discard a LEGITIMATE one without touching the tracker.
|
|
202
|
+
if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
|
|
150
203
|
process.exit(0);
|
|
151
204
|
}
|
|
152
205
|
const file = input.file_path || input.notebook_path;
|
|
@@ -177,7 +230,19 @@ const STOP_BODY = `#!/usr/bin/env node
|
|
|
177
230
|
// after 3 unresolved blocks — a sentinel bug never bricks the session — and
|
|
178
231
|
// the fail-open is recorded in the state. \`--status\` prints, never blocks.
|
|
179
232
|
import { spawnSync } from "node:child_process";
|
|
233
|
+
import { readFileSync } from "node:fs";
|
|
234
|
+
import { homedir } from "node:os";
|
|
235
|
+
import { join } from "node:path";
|
|
180
236
|
import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
237
|
+
// KJC-TSK-0774: when the turn ends green with cards closed, remind the user
|
|
238
|
+
// where to look — the board URL if it runs (pid file alive), else how to start it.
|
|
239
|
+
const boardHint = () => {
|
|
240
|
+
const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
|
|
241
|
+
try {
|
|
242
|
+
process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
|
|
243
|
+
return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
|
|
244
|
+
} catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
|
|
245
|
+
};
|
|
181
246
|
if (process.argv.includes("--status")) {
|
|
182
247
|
const st = load();
|
|
183
248
|
const branch = branchOf();
|
|
@@ -194,7 +259,7 @@ process.stdin.on("data", (d) => { raw += d; });
|
|
|
194
259
|
process.stdin.on("end", () => {
|
|
195
260
|
try {
|
|
196
261
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
197
|
-
const { session_id: sid = "default" } = JSON.parse(raw);
|
|
262
|
+
const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
|
|
198
263
|
const state = load();
|
|
199
264
|
// Tamper check runs regardless of session state: shell indirection leaves
|
|
200
265
|
// no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
|
|
@@ -216,14 +281,38 @@ process.stdin.on("end", () => {
|
|
|
216
281
|
process.exit(2);
|
|
217
282
|
}
|
|
218
283
|
state.tamper_blocks = 0;
|
|
284
|
+
// CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
|
|
285
|
+
// final message, crossed against that turn's outputs. It runs even without a
|
|
286
|
+
// tracked session — a turn with no edits still ends with a final message. The
|
|
287
|
+
// hook carries no policy: kj reads method_gates.claims and answers 0 (off,
|
|
288
|
+
// clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
|
|
289
|
+
// missing kj or an errored spawn fails open.
|
|
290
|
+
let claimsNote = "";
|
|
291
|
+
let claimsBlock = null;
|
|
292
|
+
if (transcript) {
|
|
293
|
+
const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
|
|
294
|
+
if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
|
|
295
|
+
else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
|
|
296
|
+
}
|
|
219
297
|
const s = state.sessions?.[sid];
|
|
220
|
-
if (!s)
|
|
298
|
+
if (!s) {
|
|
299
|
+
if (claimsBlock) { console.error("kj sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock); process.exit(2); }
|
|
300
|
+
if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
221
303
|
const v = violations(s, branchOf());
|
|
304
|
+
if (claimsBlock) v.push(claimsBlock);
|
|
222
305
|
if (!v.length) {
|
|
223
306
|
s.blocks = 0;
|
|
307
|
+
const notes = [];
|
|
308
|
+
if (claimsNote) notes.push(claimsNote);
|
|
309
|
+
if ((s.escapes || []).length) notes.push("kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
310
|
+
if ((s.closed_cards || []).length) {
|
|
311
|
+
notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
312
|
+
s.closed_cards = [];
|
|
313
|
+
}
|
|
224
314
|
save(state);
|
|
225
|
-
if ((
|
|
226
|
-
console.log(JSON.stringify({ systemMessage: "kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status." }));
|
|
315
|
+
if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
|
|
227
316
|
process.exit(0);
|
|
228
317
|
}
|
|
229
318
|
s.blocks = (s.blocks || 0) + 1;
|
|
@@ -251,7 +340,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
251
340
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
252
341
|
import { spawnSync } from "node:child_process";
|
|
253
342
|
import { homedir } from "node:os";
|
|
254
|
-
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
343
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
255
344
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
256
345
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
257
346
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -260,7 +349,7 @@ let raw = "";
|
|
|
260
349
|
process.stdin.on("data", (d) => { raw += d; });
|
|
261
350
|
process.stdin.on("end", () => {
|
|
262
351
|
try {
|
|
263
|
-
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
352
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
|
|
264
353
|
// Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
|
|
265
354
|
// KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
|
|
266
355
|
// only the human, editing outside it.
|
|
@@ -313,11 +402,36 @@ process.stdin.on("end", () => {
|
|
|
313
402
|
if (tok === name + "=1") found = true;
|
|
314
403
|
}
|
|
315
404
|
if (!found) return false;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
405
|
+
// KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
|
|
406
|
+
// mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
|
|
407
|
+
// comillas dobles $ y backtick siguen expandiendo (se rechazan); en
|
|
408
|
+
// comillas simples todo es literal. Y si el escape esta pero se ignora,
|
|
409
|
+
// SE DICE: un escape ignorado en silencio era el bug.
|
|
410
|
+
const bad = escSimple();
|
|
411
|
+
if (bad === null) return true;
|
|
412
|
+
if (!escNoted.has(name)) {
|
|
413
|
+
escNoted.add(name);
|
|
414
|
+
console.error("kj sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)");
|
|
319
415
|
}
|
|
320
|
-
return
|
|
416
|
+
return false;
|
|
417
|
+
};
|
|
418
|
+
const escNoted = new Set();
|
|
419
|
+
const escSimple = () => {
|
|
420
|
+
let q = null;
|
|
421
|
+
for (let i = 0; i < CMD_TEXT.length; i += 1) {
|
|
422
|
+
const c = CMD_TEXT[i];
|
|
423
|
+
if (q === "'") { if (c === "'") q = null; continue; }
|
|
424
|
+
if (q === '"') {
|
|
425
|
+
if (c === '"') q = null;
|
|
426
|
+
else if (c === "$" || c === ESC_BT) return c;
|
|
427
|
+
else if (c === "\\\\") i += 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (c === "\\\\") { i += 1; continue; }
|
|
431
|
+
if (c === "'" || c === '"') { q = c; continue; }
|
|
432
|
+
if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
|
|
433
|
+
}
|
|
434
|
+
return q === null ? null : "comilla sin cerrar";
|
|
321
435
|
};
|
|
322
436
|
// IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
|
|
323
437
|
// run under an account other than the one THIS CLONE declared (the incident:
|
|
@@ -615,6 +729,37 @@ process.stdin.on("end", () => {
|
|
|
615
729
|
}
|
|
616
730
|
}
|
|
617
731
|
if (EDIT_TOOLS.includes(tool)) {
|
|
732
|
+
// STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
|
|
733
|
+
// STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
|
|
734
|
+
// almost never — only security and persistent red main, and only when
|
|
735
|
+
// the project adopted it (method_gates.steward: block). Unknown is never
|
|
736
|
+
// treated as broken: nothing is asserted that is not known. No report =
|
|
737
|
+
// the project does not use the Steward yet — silence, adoption is explicit.
|
|
738
|
+
try {
|
|
739
|
+
const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
|
|
740
|
+
const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
|
|
741
|
+
const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
|
|
742
|
+
if (bad.length || stale.length) {
|
|
743
|
+
const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
|
|
744
|
+
let blockOn = false;
|
|
745
|
+
try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
|
|
746
|
+
if (hard.length && blockOn) {
|
|
747
|
+
if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
|
|
748
|
+
else {
|
|
749
|
+
console.error("kj sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)");
|
|
750
|
+
process.exit(2);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const st = load();
|
|
754
|
+
const s = session(st, sid);
|
|
755
|
+
if (!s.steward_informed) {
|
|
756
|
+
s.steward_informed = true;
|
|
757
|
+
save(st);
|
|
758
|
+
const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
|
|
759
|
+
console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
} catch { /* no report or unreadable — the Steward is not adopted here */ }
|
|
618
763
|
const file = input.file_path || input.notebook_path;
|
|
619
764
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
620
765
|
if (rel && CODE.test(rel) && !TESTS.test(rel)) {
|
|
@@ -648,6 +793,19 @@ process.stdin.on("end", () => {
|
|
|
648
793
|
}
|
|
649
794
|
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
650
795
|
// gh confirms "merged pull request #N" — a failed merge never blocks)
|
|
796
|
+
// CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
|
|
797
|
+
// there misleads every future reader. When the command writes one from a
|
|
798
|
+
// file, kj crosses ITS data against this turn's outputs; the hook carries
|
|
799
|
+
// no policy (kj reads method_gates.claims; off = this check does not exist).
|
|
800
|
+
const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
|
|
801
|
+
if (bodyFile && transcript) {
|
|
802
|
+
const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
|
|
803
|
+
const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
|
|
804
|
+
if (!g.error && g.status === 2) {
|
|
805
|
+
console.error("kj sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body);
|
|
806
|
+
process.exit(2);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
651
809
|
if (PUBLISH.test(cmd)) {
|
|
652
810
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
653
811
|
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
@@ -659,7 +817,15 @@ process.stdin.on("end", () => {
|
|
|
659
817
|
process.exit(2);
|
|
660
818
|
}
|
|
661
819
|
} else if (PUSH.test(cmd)) {
|
|
662
|
-
|
|
820
|
+
// KJC-BUG-0147: the board escape covers the push too — a pending move
|
|
821
|
+
// blocks the push like it blocks the commit, and the SAME conscious,
|
|
822
|
+
// recorded escape opens both (multi-PR cards are a legitimate plan).
|
|
823
|
+
const sess = load().sessions?.[sid];
|
|
824
|
+
let v = violations(sess, branchOf());
|
|
825
|
+
if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
|
|
826
|
+
recordEscape(sid, "KJ_ALLOW_BOARD", tool);
|
|
827
|
+
v = violations({ ...sess, pending_moves: [] }, branchOf());
|
|
828
|
+
}
|
|
663
829
|
if (v.length) {
|
|
664
830
|
console.error("kj sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status");
|
|
665
831
|
process.exit(2);
|
|
@@ -12,8 +12,9 @@ import { join } from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
15
16
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
16
|
-
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
|
+
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
18
|
|
|
18
19
|
// La versión del kj que corre harden — es la que se pinea en el fallback
|
|
19
20
|
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
@@ -85,7 +86,12 @@ export function installWorkflows({
|
|
|
85
86
|
const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
|
|
86
87
|
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
|
|
87
88
|
: null;
|
|
88
|
-
|
|
89
|
+
// STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
|
|
90
|
+
// steward.action: true in its config; nothing is imposed by default.
|
|
91
|
+
let stewardOn = false;
|
|
92
|
+
try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
|
|
93
|
+
const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
|
|
94
|
+
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
|
|
89
95
|
const results = [];
|
|
90
96
|
for (const wf of all) {
|
|
91
97
|
const target = join(dir, wf.file);
|
|
@@ -186,7 +186,10 @@ export const SHRINK_BUDGET_WORKFLOW = [
|
|
|
186
186
|
" env:",
|
|
187
187
|
" BASE_REF: ${{ github.base_ref }}",
|
|
188
188
|
" run: |",
|
|
189
|
-
|
|
189
|
+
// KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
|
|
190
|
+
// all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
|
|
191
|
+
// gate applies the same exclusions kj's own repo applies.
|
|
192
|
+
" d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
|
|
190
193
|
' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
|
|
191
194
|
' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
|
|
192
195
|
' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
|
|
@@ -272,6 +275,40 @@ export function mutationWorkflowFor(language, pm = "npm") {
|
|
|
272
275
|
return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
// STW-E (KJC-TSK-0793): the scheduled Steward sweep — OPT-IN via
|
|
279
|
+
// steward.action: true. Decay happens precisely on the days nobody looks, so
|
|
280
|
+
// the watcher cannot live inside a session: the Action runs where the repo
|
|
281
|
+
// lives, publishes the SAME report file every other mode writes (one source
|
|
282
|
+
// of truth), and a broken invariant never stops the report from publishing.
|
|
283
|
+
export function stewardWorkflowFor(kjVersion, pkgName = "karajan-code") {
|
|
284
|
+
return [
|
|
285
|
+
"name: Steward sweep",
|
|
286
|
+
"on:",
|
|
287
|
+
" schedule:",
|
|
288
|
+
' - cron: "17 6 * * 1"',
|
|
289
|
+
" workflow_dispatch: {}",
|
|
290
|
+
"permissions:",
|
|
291
|
+
" contents: write",
|
|
292
|
+
"jobs:",
|
|
293
|
+
" sweep:",
|
|
294
|
+
" runs-on: ubuntu-latest",
|
|
295
|
+
" steps:",
|
|
296
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
297
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
298
|
+
" with:",
|
|
299
|
+
' node-version: "22"',
|
|
300
|
+
` - run: npm install -g ${pkgName}@${kjVersion}`,
|
|
301
|
+
" - name: Sweep (a broken invariant must not stop the report)",
|
|
302
|
+
" env:",
|
|
303
|
+
" GH_TOKEN: ${{ github.token }}",
|
|
304
|
+
" run: kj steward sweep || true",
|
|
305
|
+
" - name: Publish the report and the proposed cards",
|
|
306
|
+
" run: |",
|
|
307
|
+
" git add .karajan/steward .karajan/plans 2>/dev/null || true",
|
|
308
|
+
' git diff --cached --quiet || { git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(steward): sweep report [skip ci]" && git push; }',
|
|
309
|
+
].join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
275
312
|
/** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
|
|
276
313
|
export function extraWorkflowsFor({ profile = "standard", publishable = false, pm = "npm" } = {}) {
|
|
277
314
|
const extras = [];
|
package/src/policy/exceptions.js
CHANGED
|
@@ -24,13 +24,23 @@ function defaultIdentity(projectDir) {
|
|
|
24
24
|
* @returns {{standing: object[], discarded: number}}
|
|
25
25
|
*/
|
|
26
26
|
export function loadStandingExceptions(projectDir) {
|
|
27
|
+
const { records, discarded } = loadExceptionRecords(projectDir);
|
|
28
|
+
return { standing: records.filter((rec) => rec.scopeKind === "permanente"), discarded };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TODOS los registros del jsonl (permanentes y puntuales) con el mismo parse
|
|
33
|
+
* tolerante — el informe (PL-E) cuenta también las puntuales.
|
|
34
|
+
* @returns {{records: object[], discarded: number}}
|
|
35
|
+
*/
|
|
36
|
+
export function loadExceptionRecords(projectDir) {
|
|
27
37
|
let raw;
|
|
28
38
|
try {
|
|
29
39
|
raw = readFileSync(join(projectDir, ".karajan", "policy-exceptions.jsonl"), "utf8");
|
|
30
40
|
} catch {
|
|
31
|
-
return {
|
|
41
|
+
return { records: [], discarded: 0 };
|
|
32
42
|
}
|
|
33
|
-
const
|
|
43
|
+
const records = [];
|
|
34
44
|
let discarded = 0;
|
|
35
45
|
for (const line of raw.split("\n")) {
|
|
36
46
|
if (!line.trim()) continue;
|
|
@@ -39,12 +49,12 @@ export function loadStandingExceptions(projectDir) {
|
|
|
39
49
|
// JSON válido pero no-objeto (null, número…) es tan corrupto como el
|
|
40
50
|
// que no parsea: se descarta contando (catch de codex, explícito).
|
|
41
51
|
if (typeof rec !== "object" || rec === null) discarded += 1;
|
|
42
|
-
else
|
|
52
|
+
else records.push(rec);
|
|
43
53
|
} catch {
|
|
44
54
|
discarded += 1;
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
|
-
return {
|
|
57
|
+
return { records, discarded };
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function defaultAppend(projectDir, line) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PL-E (KJC-TSK-0767) — el informe de policy, PURO (líneas del decision log +
|
|
3
|
+
* excepciones + policy + reloj ⇒ objeto determinista; cero I/O, cero LLM).
|
|
4
|
+
* Responde: ¿qué reglas avisan y cuánto (gana dientes con datos)?, ¿qué
|
|
5
|
+
* denegaciones siguen abiertas (la renuncia deja rastro)?, ¿qué concesiones
|
|
6
|
+
* viven, vencen o se renuevan (la renovación es señal)?
|
|
7
|
+
*/
|
|
8
|
+
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
9
|
+
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
const WARN_SIGNAL_MIN = 5;
|
|
12
|
+
|
|
13
|
+
/** enforcement/class de una regla según la policy cargada (null si no se sabe). */
|
|
14
|
+
function ruleMeta(policy, ruleId) {
|
|
15
|
+
if (!policy) return {};
|
|
16
|
+
if (ruleId.startsWith("defaults.")) return { enforcement: "deny", class: "security" };
|
|
17
|
+
const m = /^roles\.([^.]+)\.([^.]+)/.exec(ruleId);
|
|
18
|
+
const spec = m ? policy.roles?.[m[1]]?.[m[2]] : policy.invariants?.find((i) => i.id === ruleId);
|
|
19
|
+
if (!spec) return {};
|
|
20
|
+
return { enforcement: spec.enforcement || "warn", ...(spec.class ? { class: spec.class } : {}) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseDecisions(lines) {
|
|
24
|
+
const records = [];
|
|
25
|
+
let discarded = 0;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
try {
|
|
28
|
+
const rec = JSON.parse(line);
|
|
29
|
+
if (typeof rec === "object" && rec !== null) records.push(rec);
|
|
30
|
+
else discarded += 1;
|
|
31
|
+
} catch {
|
|
32
|
+
discarded += 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { records, discarded };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Abiertas = denies posteriores al último allow/exempt: el log acaba en
|
|
39
|
+
// rechazo sin que nada haya pasado después. Heurística declarada — el log
|
|
40
|
+
// no lleva rama ni autor, así que no distingue "arreglado" de "abandonado".
|
|
41
|
+
const lastResolvedIndex = (records) => records.findLastIndex((r) => r.decision === "allow" || r.decision === "exempt");
|
|
42
|
+
|
|
43
|
+
function tallyRules(records, policy, lastResolved) {
|
|
44
|
+
const rules = new Map();
|
|
45
|
+
const row = (id) => {
|
|
46
|
+
if (!rules.has(id)) rules.set(id, { rule_id: id, ...ruleMeta(policy, id), warns: 0, denies: 0, exempts: 0, open: 0 });
|
|
47
|
+
return rules.get(id);
|
|
48
|
+
};
|
|
49
|
+
// Un sello lleva un rule_id por FICHERO violador: se cuentan decisiones
|
|
50
|
+
// por regla, no ficheros — de ahí el Set por entrada.
|
|
51
|
+
const ids = (list) => new Set(list ?? []);
|
|
52
|
+
records.forEach((rec, i) => {
|
|
53
|
+
for (const id of ids(rec.warn_rule_ids)) row(id).warns += 1;
|
|
54
|
+
if (rec.decision === "deny") for (const id of ids(rec.rule_ids)) { row(id).denies += 1; if (i > lastResolved) row(id).open += 1; }
|
|
55
|
+
if (rec.decision === "exempt") for (const id of ids(rec.rule_ids)) row(id).exempts += 1;
|
|
56
|
+
});
|
|
57
|
+
return [...rules.values()].toSorted((a, b) => (b.denies + b.warns) - (a.denies + a.warns));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tallyGrants(exceptionRecords, now, soonDays) {
|
|
61
|
+
const perm = exceptionRecords.filter((e) => e?.scopeKind === "permanente");
|
|
62
|
+
// Una caducidad ilegible (NaN) NO está viva: la excepción falla cerrada.
|
|
63
|
+
const alive = [];
|
|
64
|
+
const expired = [];
|
|
65
|
+
for (const e of perm) (Date.parse(e.expiresAt) > now.getTime() ? alive : expired).push(e);
|
|
66
|
+
const soon = alive.filter((e) => Date.parse(e.expiresAt) - now.getTime() <= soonDays * DAY_MS);
|
|
67
|
+
const counts = Map.groupBy(perm, (e) => e.rule_id);
|
|
68
|
+
const renewals = [...counts].filter(([, v]) => v.length >= 2).map(([rule_id, v]) => ({ rule_id, count: v.length }));
|
|
69
|
+
return { alive, expired, soon, renewals, point: exceptionRecords.length - perm.length };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function signalsFor(rules, grants) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const g of grants.renewals) out.push(`[${g.rule_id}] concedida ${g.count} veces — una excepción que se renueva ya no es una excepción: es la política pidiendo cambio (PR a .karajan/policy.yml)`);
|
|
75
|
+
for (const r of rules) {
|
|
76
|
+
if (r.warns >= WARN_SIGNAL_MIN && r.denies === 0 && r.enforcement !== "deny") out.push(`[${r.rule_id}] ha avisado ${r.warns} veces y nunca ha bloqueado — decide: promover a enforcement=deny o retirarla; un aviso perpetuo es ruido`);
|
|
77
|
+
if (r.open > 0) out.push(`[${r.rule_id}] ${r.open} denegación(es) abierta(s) — el log acaba en rechazo: ¿arreglo pendiente, excepción por pedir, o renuncia?`);
|
|
78
|
+
}
|
|
79
|
+
for (const g of grants.soon) out.push(`[${g.rule_id}] vence ${g.expiresAt} — al vencer vuelve a bloquear sola; renueva por su cauce o deja que caduque`);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @returns {{chain: object, decisions: object, rules: object[], grants: object, signals: string[]}}
|
|
85
|
+
*/
|
|
86
|
+
export function buildPolicyReport({ decisionLines = [], exceptionRecords = [], policy = null, now = new Date(), soonDays = 7 } = {}) {
|
|
87
|
+
const chain = decisionLines.length ? verifyDecisionChain(decisionLines) : { ok: true, length: 0 };
|
|
88
|
+
const { records, discarded } = parseDecisions(decisionLines);
|
|
89
|
+
const count = (d) => records.filter((r) => r.decision === d).length;
|
|
90
|
+
const chokepoints = Object.fromEntries([...Map.groupBy(records, (r) => r.chokepoint ?? "unknown")].map(([k, v]) => [k, v.length]));
|
|
91
|
+
const lastResolved = lastResolvedIndex(records);
|
|
92
|
+
const rules = tallyRules(records, policy, lastResolved);
|
|
93
|
+
const grants = tallyGrants(exceptionRecords, now, soonDays);
|
|
94
|
+
const decisions = {
|
|
95
|
+
total: records.length, discarded, allow: count("allow"), deny: count("deny"), exempt: count("exempt"),
|
|
96
|
+
open: records.filter((r, i) => r.decision === "deny" && i > lastResolved).length, chokepoints,
|
|
97
|
+
};
|
|
98
|
+
return { chain, decisions, rules, grants, signals: signalsFor(rules, grants) };
|
|
99
|
+
}
|