karajan-code 4.38.0 → 4.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/ai-trash/src/destructive-parser.js +34 -5
- package/packages/ai-trash/src/hook.js +28 -6
- package/src/audit/ai-slop-findings.js +4 -2
- package/src/audit/circular-deps.js +8 -7
- package/src/audit/webperf-input.js +3 -1
- package/src/checks/method.js +1 -1
- package/src/checks/release-check.js +3 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/cli/register-pipeline.js +2 -1
- package/src/commands/init.js +6 -2
- package/src/commands/review-gate.js +21 -22
- package/src/commands/rules-approve.js +59 -0
- package/src/commands/rules-compile.js +116 -0
- package/src/commands/rules-decide.js +51 -0
- package/src/commands/rules-review.js +44 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/harden/config-templates.js +3 -0
- package/src/harden/harness-hooks.js +7 -85
- package/src/harden/hook-templates.js +6 -5
- package/src/harden/human-act.js +70 -0
- package/src/harden/phone-sign.js +26 -0
- package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
- package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
- package/src/harden/sentinel/sentinel-discard.mjs +33 -4
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +34 -2
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +111 -194
- package/src/harden/supervisor-commit.js +13 -56
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/sovereignty-guard.js +16 -15
- package/src/orchestrator/preflight-checks.js +4 -3
- package/src/policy/supervisor-verify.js +20 -6
- package/src/privacy/scan.js +7 -0
- package/src/review/card-first.js +3 -5
- package/src/review/gate-gitignore.js +8 -0
- package/src/review/policy-gate.js +4 -6
- package/src/review/sonar-pregate.js +3 -3
- package/src/review/tests-with-code.js +3 -6
- package/src/rules/approval-view.js +49 -0
- package/src/rules/compiled.js +108 -0
- package/src/rules/coverage.js +23 -0
- package/src/rules/evaluate.js +60 -0
- package/src/rules/inventory.js +118 -0
- package/src/sonar/api.js +21 -0
- package/src/sonar/config-resolver.js +19 -1
- package/src/sonar/scanner.js +22 -3
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layers that make a command a HUMAN act (ADR 0009), out of the supervisor's
|
|
3
|
+
* seal so that other human-only commands use the very same ones (KJC-TSK-0951).
|
|
4
|
+
*
|
|
5
|
+
* 1-2. the environment and the terminal: an agent session declares itself;
|
|
6
|
+
* 3. the process ancestry: a kj launched by an agent DESCENDS from it, and
|
|
7
|
+
* that is in /proc whoever fakes the pty or cleans the environment;
|
|
8
|
+
* 4. a random nonce typed back, read from the real controlling terminal.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { closeSync, openSync, readFileSync, readSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
14
|
+
|
|
15
|
+
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
|
|
16
|
+
const read = readProc || ((p) => {
|
|
17
|
+
const stat = readFileSync(`/proc/${p}/stat`, "utf8");
|
|
18
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
19
|
+
let cmd = "";
|
|
20
|
+
try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
|
|
21
|
+
return { ppid, cmd };
|
|
22
|
+
});
|
|
23
|
+
let cur = pid;
|
|
24
|
+
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
25
|
+
let info;
|
|
26
|
+
try { info = read(cur); } catch { return { agent: false, unknown: true }; }
|
|
27
|
+
if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
|
|
28
|
+
if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
|
|
29
|
+
cur = info.ppid;
|
|
30
|
+
}
|
|
31
|
+
return { agent: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Layers 1 to 3: throws when `what` runs inside an agent session. */
|
|
35
|
+
export function refuseAgentSession(what, { env = process.env, tty = process.stdout.isTTY, ancestry = {} } = {}) {
|
|
36
|
+
if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
|
|
37
|
+
throw new Error(`${what} es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)`);
|
|
38
|
+
}
|
|
39
|
+
const anc = agentAncestry(ancestry);
|
|
40
|
+
if (anc.agent) {
|
|
41
|
+
throw new Error(`${what} es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The answer comes from the process's REAL tty (not stdin, which an attacker
|
|
46
|
+
// feeds through a pipe): /dev/tty only exists with a controlling terminal.
|
|
47
|
+
function readTty(what, nonce) {
|
|
48
|
+
process.stdout.write(`${what}: teclea "${nonce}" para confirmar que eres humano: `);
|
|
49
|
+
try {
|
|
50
|
+
const buf = Buffer.alloc(64);
|
|
51
|
+
const fd = openSync("/dev/tty", "r");
|
|
52
|
+
const n = readSync(fd, buf, 0, 64);
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
return buf.toString("utf8", 0, n).trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Layer 4: a random nonce typed back. A blind feeder does not know the code;
|
|
62
|
+
* automating its reading takes an expect driver, which is premeditation.
|
|
63
|
+
* @param {string} what
|
|
64
|
+
* @param {(nonce: string) => string|null} [confirm] test seam
|
|
65
|
+
*/
|
|
66
|
+
export function confirmHuman(what, confirm = null) {
|
|
67
|
+
const nonce = randomBytes(3).toString("hex");
|
|
68
|
+
const answer = confirm ? confirm(nonce) : readTty(what, nonce);
|
|
69
|
+
if (answer !== nonce) throw new Error(`${what}: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
70
|
+
}
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -132,6 +132,32 @@ export function verifyPhoneSignature({ payload, signature, publicKey }) {
|
|
|
132
132
|
return verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"));
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* KJC-TSK-0964 (HUM-A, ADR 0018): the seal's signature, checked where the
|
|
137
|
+
* machine that made it has no say. With signers in the versioned roster, a
|
|
138
|
+
* provenance must be signed by one of them over the very files it declares.
|
|
139
|
+
* With no roster nothing is required: the guarantee is local only.
|
|
140
|
+
* @returns {{required: boolean, ok: boolean, reason?: string}}
|
|
141
|
+
*/
|
|
142
|
+
export function provenanceSignature({ projectDir, provenance }) {
|
|
143
|
+
const roster = readSigners({ projectDir });
|
|
144
|
+
if (roster.length === 0) return { required: false, ok: true };
|
|
145
|
+
const fail = (reason) => ({ required: true, ok: false, reason });
|
|
146
|
+
const sig = provenance?.signature;
|
|
147
|
+
if (typeof sig?.signature !== "string" || typeof sig?.signer !== "string") return fail("el padrón tiene firmantes y la procedencia no lleva firma");
|
|
148
|
+
if (!roster.includes(sig.signer)) return fail("la clave que firma la procedencia no está en el padrón");
|
|
149
|
+
// The files as the phone signed them: {file, sha256}, a deletion with no hash.
|
|
150
|
+
// A provenance is read from the project: a malformed one is rejected, it does not crash.
|
|
151
|
+
const entries = Array.isArray(provenance.files) ? provenance.files : [];
|
|
152
|
+
if (entries.some((entry) => typeof entry?.file !== "string")) return fail("la procedencia declara ficheros mal formados");
|
|
153
|
+
const files = entries.map(({ file, sha256 }) => ({ file, sha256: sha256 ?? "" }));
|
|
154
|
+
let good = false;
|
|
155
|
+
try {
|
|
156
|
+
good = verifyPhoneSignature({ payload: canonicalPayload({ ...sig, files }), signature: sig.signature, publicKey: sig.signer });
|
|
157
|
+
} catch { /* a malformed key or signature is a signature that does not verify */ }
|
|
158
|
+
return good ? { required: true, ok: true } : fail("la firma no corresponde a los ficheros que la procedencia declara");
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
/**
|
|
136
162
|
* Publica la petición en Firestore, enseña QR + URL, pollea el doc y verifica
|
|
137
163
|
* la firma. Sin fallbacks silenciosos: red caída o HTTP no-ok ⇒ throw. Firma
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kj sentinel rules hook (KJC-TSK-0946, MDR-C2, ADR 0016), managed by `kj harden`.
|
|
3
|
+
// PreToolUse with NO matcher: every tool call, MCP ones included, passes the rules
|
|
4
|
+
// of the MD files (compiled into .karajan/rules.yml) before it runs. Exit 2 blocks
|
|
5
|
+
// the call and stderr says which rule. The decision is the gate's (sentinel-rules.mjs).
|
|
6
|
+
// Copied byte for byte into .karajan/harness.
|
|
7
|
+
import console from "node:console";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { ROOT, doc } from "./sentinel-lib.mjs";
|
|
11
|
+
import { rulesGate } from "./sentinel-rules.mjs";
|
|
12
|
+
|
|
13
|
+
let raw = "";
|
|
14
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
15
|
+
process.stdin.on("end", () => {
|
|
16
|
+
let call;
|
|
17
|
+
try { call = JSON.parse(raw); } catch { process.exit(0); } // not a tool call: nothing to judge
|
|
18
|
+
// The user's switch for the whole Sentinel, set in the host's environment.
|
|
19
|
+
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
20
|
+
const verdict = rulesGate({ root: ROOT, tool: call?.tool_name, input: call?.tool_input, run: spawnSync });
|
|
21
|
+
if (!verdict.deny) process.exit(0);
|
|
22
|
+
console.error("karajan sentinel: " + verdict.message + doc("rules"));
|
|
23
|
+
console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la regla para pasarlo; si te parece injusta, díselo a tu usuario o usa kj report-issue.");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync, realpathSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
-
import { headIndex, shellSegments } from "./sentinel-shell.mjs";
|
|
8
|
+
import { headIndex, shellSegments, withoutRedirections } from "./sentinel-shell.mjs";
|
|
9
9
|
|
|
10
10
|
const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
|
|
11
11
|
const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
|
|
@@ -56,11 +56,7 @@ export const shellWrites = (words) => {
|
|
|
56
56
|
// xargs / find -exec run a writer on targets that arrive at run time.
|
|
57
57
|
if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
|
|
58
58
|
// Arguments without redirections (< << <<< > >> and a detached operand).
|
|
59
|
-
const rest =
|
|
60
|
-
for (let k = i + 1; k < words.length; k++) {
|
|
61
|
-
if (!/^\d*[<>]/.test(words[k])) rest.push(words[k]);
|
|
62
|
-
else if (/^\d*(<{1,3}|>>?|>[|&])$/.test(words[k])) k++;
|
|
63
|
-
}
|
|
59
|
+
const rest = withoutRedirections(words.slice(i + 1));
|
|
64
60
|
// Operands: words that are not options, and EVERY word after "--" (touch -- -file).
|
|
65
61
|
const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
|
|
66
62
|
const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// ADR 0014). An agent does not discard changes it did not make. Copied byte for
|
|
3
3
|
// byte into .karajan/harness; `root` is the project root, injected.
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { relative, resolve } from "node:path";
|
|
5
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
|
-
import { headIndex, shortOpts } from "./sentinel-shell.mjs";
|
|
7
|
+
import { headIndex, shortOpts, withoutRedirections } from "./sentinel-shell.mjs";
|
|
8
8
|
|
|
9
9
|
export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch", "clean"];
|
|
10
10
|
|
|
@@ -16,7 +16,9 @@ export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch",
|
|
|
16
16
|
* @param {string[]} words
|
|
17
17
|
* @param {string} root
|
|
18
18
|
*/
|
|
19
|
-
export const discardOf = (
|
|
19
|
+
export const discardOf = (command, root) => {
|
|
20
|
+
// KJC-BUG-0268: `git checkout main 2>&1` names a branch, not a branch and a path.
|
|
21
|
+
const words = withoutRedirections(command);
|
|
20
22
|
let i = headIndex(words, ["git"]); // /usr/bin/git is git
|
|
21
23
|
// A $variable in command position beside a discard verb ($g checkout) cannot be read.
|
|
22
24
|
if (words[i]?.startsWith("$") && words.some((w) => DISCARD_VERBS.includes(w))) return { unknown: true };
|
|
@@ -58,6 +60,30 @@ export const discardOf = (words, root) => {
|
|
|
58
60
|
return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* KJC-BUG-0261: the tracked files an `rm` or `git rm` removes, relative to root.
|
|
65
|
+
* Removing a file is the session touching it, so its state is noted before it
|
|
66
|
+
* goes and restoring it later is not taken for someone else's change.
|
|
67
|
+
* @param {string[]} words
|
|
68
|
+
* @param {string} root
|
|
69
|
+
* @param {string} cwd where relative paths resolve (the session's cwd)
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
export const removedFiles = (words, root, cwd = process.cwd()) => {
|
|
73
|
+
const i = headIndex(words, ["rm", "git"]);
|
|
74
|
+
const head = words[i]?.split("/").at(-1);
|
|
75
|
+
const isGitRm = head === "git" && words[i + 1] === "rm";
|
|
76
|
+
if (head !== "rm" && !isGitRm) return [];
|
|
77
|
+
const args = words.slice(i + (isGitRm ? 2 : 1));
|
|
78
|
+
const dd = args.indexOf("--");
|
|
79
|
+
const named = dd < 0 ? args.filter((a) => !a.startsWith("-")) : [...args.slice(0, dd).filter((a) => !a.startsWith("-")), ...args.slice(dd + 1)];
|
|
80
|
+
const rels = named.map((p) => relative(root, resolve(cwd, p))).filter((r) => r && !r.startsWith("..") && !isAbsolute(r));
|
|
81
|
+
if (rels.length === 0) return [];
|
|
82
|
+
// ls-files expands a directory (rm -r dir) into the tracked files it holds.
|
|
83
|
+
const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--", ...rels], { encoding: "utf8" });
|
|
84
|
+
return r.status === 0 ? String(r.stdout).split("\0").filter(Boolean) : [];
|
|
85
|
+
};
|
|
86
|
+
|
|
61
87
|
/**
|
|
62
88
|
* Files the discard would lose that the session did not own: dirty now and not
|
|
63
89
|
* clean at first touch. Anything unreadable, or another repo, is foreign.
|
|
@@ -84,7 +110,10 @@ export const foreignLost = (d, touch, root) => {
|
|
|
84
110
|
}
|
|
85
111
|
// Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
|
|
86
112
|
// be proven safe; git would refuse to check it out anyway.
|
|
87
|
-
if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0)
|
|
113
|
+
if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) {
|
|
114
|
+
// KJC-BUG-0261: what the session removed with git rm left the index, and it is still its own.
|
|
115
|
+
return d.paths.map((p) => relative(root, resolve(d.cwd, p))).every(own) ? [] : [`(ruta no resoluble: ${d.paths.join(" ")})`];
|
|
116
|
+
}
|
|
88
117
|
const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
|
|
89
118
|
if (r.status !== 0) return ["(git status fallo)"];
|
|
90
119
|
const files = [];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The Sentinel's rules gate (KJC-TSK-0939, MDR-C, ADR 0016). A real module with
|
|
2
|
+
// no dependencies: kj harden copies it byte for byte into .karajan/harness as
|
|
3
|
+
// sentinel-rules.mjs, and this code is unit-tested.
|
|
4
|
+
//
|
|
5
|
+
// The rules written in the MD files, compiled into .karajan/rules.yml, are asked
|
|
6
|
+
// to kj before EVERY tool call. kj decides (exit 0 allow, 2 deny); whatever
|
|
7
|
+
// cannot be evaluated is denied, because rules silently off would be every call
|
|
8
|
+
// allowed. With no rules.yml the gate does not exist and costs nothing.
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const RULES_FILE = join(".karajan", "rules.yml");
|
|
14
|
+
// KJC-TSK-0954 (ADR 0017): the rules from the user's private sources, out of git.
|
|
15
|
+
// Either file is rules: a project with only local ones is gated just the same.
|
|
16
|
+
const LOCAL_RULES_FILE = join(".karajan", "rules.local.yml");
|
|
17
|
+
const hasRules = (root, exists) => exists(join(root, RULES_FILE)) || exists(join(root, LOCAL_RULES_FILE));
|
|
18
|
+
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
19
|
+
const KJ_DOES_NOT_START = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* KJC-BUG-0207: a kj that does not start (a linked tree with a syntax error) is
|
|
23
|
+
* not a verdict. Returns the file its stack names, "" when it names none, or
|
|
24
|
+
* null when kj did start.
|
|
25
|
+
* @param {string} stderr
|
|
26
|
+
*/
|
|
27
|
+
export const brokenKjFile = (stderr) => {
|
|
28
|
+
const text = String(stderr || "");
|
|
29
|
+
if (!KJ_DOES_NOT_START.test(text)) return null;
|
|
30
|
+
// The location line node prints: a path or file: URL (blanks allowed), then :line.
|
|
31
|
+
const at = text.split("\n").map((line) => line.trim()).find((line) => /^(file:|\/|[A-Za-z]:\\).*\.(m?js|cjs|ts):\d+$/.test(line)) || "";
|
|
32
|
+
const file = at.slice(0, at.lastIndexOf(":"));
|
|
33
|
+
return file.startsWith("file:") ? fileURLToPath(file) : file;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const lastJson = (stdout) => {
|
|
37
|
+
try { return JSON.parse(String(stdout || "").trim().split("\n").pop()); } catch { return null; }
|
|
38
|
+
};
|
|
39
|
+
const deny = (message) => ({ deny: true, message });
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* KJC-TSK-0949 (MDR-E2): an MD changed and what was compiled no longer covers
|
|
43
|
+
* it. One line for the session's start, or "" when every rule is decided, there
|
|
44
|
+
* is no rules.yml, or kj cannot tell (a notice never fails a session).
|
|
45
|
+
* @param {{root: string, run: Function, exists?: Function}} opts
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function rulesDriftNotice({ root, run, exists = existsSync }) {
|
|
49
|
+
if (!hasRules(root, exists)) return "";
|
|
50
|
+
const res = run("kj", ["rules", "coverage", "--json"], { cwd: root, encoding: "utf8" });
|
|
51
|
+
const counts = res.error || res.status !== 0 ? null : lastJson(res.stdout)?.counts;
|
|
52
|
+
const none = Number(counts?.none) || 0;
|
|
53
|
+
const stale = Number(counts?.stale) || 0;
|
|
54
|
+
if (none + stale === 0) return "";
|
|
55
|
+
return none + " sin gate y " + stale + " desfasada(s) entre las reglas de tus MD (" + RULES_FILE + "). Ejecuta kj rules compile, "
|
|
56
|
+
+ "escribe la propuesta y pide a tu usuario que la apruebe (kj rules approve); kj rules coverage las nombra.";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{root: string, tool: string, input?: object, run: Function, exists?: Function}} call
|
|
61
|
+
* `run` is spawnSync (injected so the gate is testable without a process).
|
|
62
|
+
* @returns {{deny: boolean, message?: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function rulesGate({ root, tool, input, run, exists = existsSync }) {
|
|
65
|
+
if (!hasRules(root, exists)) return { deny: false };
|
|
66
|
+
// The input goes on stdin: a large Write does not fit in an argument.
|
|
67
|
+
const res = run("kj", ["rules", "eval", "--tool", String(tool), "--input", "-"], { cwd: root, encoding: "utf8", input: JSON.stringify(input ?? {}) });
|
|
68
|
+
if (res.error || res.status === null) {
|
|
69
|
+
return deny(RULES_FILE + " declara reglas pero kj no es ejecutable, así que no se pueden evaluar: restaura kj en el PATH.");
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 0) return { deny: false };
|
|
72
|
+
const out = lastJson(res.stdout);
|
|
73
|
+
if (res.status === 2) {
|
|
74
|
+
const source = out?.source ? " (" + out.source + ")" : "";
|
|
75
|
+
const what = String(out?.message ?? "la acción rompe una regla de los MD").replace(/[.\s]+$/, "");
|
|
76
|
+
return deny("regla " + (out?.rule_id ?? "sin identificar") + source + ": " + what + ". Sin escape (ADR 0016): si la regla está mal compilada, la corrige tu usuario.");
|
|
77
|
+
}
|
|
78
|
+
const broken = brokenKjFile(res.stderr);
|
|
79
|
+
if (broken !== null) {
|
|
80
|
+
const target = input?.file_path || input?.notebook_path || "";
|
|
81
|
+
if (EDIT_TOOLS.has(tool) && broken && target && resolve(String(target)) === resolve(broken)) return { deny: false };
|
|
82
|
+
return deny("kj no arranca" + (broken ? " (" + broken + ")" : "") + ", así que las reglas no se pueden evaluar y nada más pasa: arregla ese fichero.");
|
|
83
|
+
}
|
|
84
|
+
const why = Array.isArray(out?.errors) ? out.errors.join("; ") : "exit " + res.status;
|
|
85
|
+
return deny(RULES_FILE + " no se puede evaluar (" + why + "): lo corrige tu usuario, fuera de la sesión.");
|
|
86
|
+
}
|
|
@@ -86,10 +86,42 @@ export const stripInertQuotes = (cmd) => {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
// Options whose value is prose, never a path (gh, git, kj).
|
|
89
|
-
const
|
|
89
|
+
const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position"]);
|
|
90
|
+
const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
90
91
|
|
|
91
92
|
/** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
|
|
92
|
-
export const stripTextOptionValues = (cmd) => cmd.replace(
|
|
93
|
+
export const stripTextOptionValues = (cmd) => cmd.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* KJC-BUG-0245: the values of the named options, read by the shell reader, so a
|
|
97
|
+
* `;`, `&&` or `|` right after a path ends the word instead of joining it.
|
|
98
|
+
* Covers `--opt value` and `--opt=value`.
|
|
99
|
+
* @param {string} cmd
|
|
100
|
+
* @param {string[]} names
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) => words.flatMap((w, i) => {
|
|
104
|
+
if (names.includes(w) && i + 1 < words.length) return [words[i + 1]];
|
|
105
|
+
const eq = w.indexOf("=");
|
|
106
|
+
return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* KJC-BUG-0268: the words of a simple command without its redirections (2>&1,
|
|
111
|
+
* >file, < file, >> log...) and the operand a detached operator takes. A
|
|
112
|
+
* redirection is where output goes, never an argument of the command.
|
|
113
|
+
* @param {string[]} words
|
|
114
|
+
*/
|
|
115
|
+
export const withoutRedirections = (words) => {
|
|
116
|
+
const rest = [];
|
|
117
|
+
let operand = false; // the previous word was a bare operator: this one is its target
|
|
118
|
+
for (const word of words) {
|
|
119
|
+
if (operand) operand = false;
|
|
120
|
+
else if (!/^\d*[<>]/.test(word)) rest.push(word);
|
|
121
|
+
else operand = /^\d*(<{1,3}|>>?|>[|&])$/.test(word);
|
|
122
|
+
}
|
|
123
|
+
return rest;
|
|
124
|
+
};
|
|
93
125
|
|
|
94
126
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
95
127
|
export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// kj sentinel SessionStart hook (KJC-TSK-0918, ADR 0014), managed by `kj harden`.
|
|
3
3
|
// A compaction takes exactly what the agent needs most: the rules it read at the
|
|
4
4
|
// start. After a compaction or a resume, this gives them back, short, with the
|
|
5
|
-
// state of the session. A new session gets
|
|
5
|
+
// state of the session. A new session gets no rules: CLAUDE.md already brings them.
|
|
6
6
|
// Copied byte for byte into .karajan/harness; never fails a session (exit 0).
|
|
7
7
|
import process from "node:process";
|
|
8
|
-
import {
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { CARD, ROOT, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
|
|
10
|
+
import { rulesDriftNotice } from "./sentinel-rules.mjs";
|
|
9
11
|
|
|
10
12
|
const RULES = [
|
|
11
13
|
"1. Karajan gobierna y se le obedece: no cambies políticas, configuración de gates ni exclusiones para pasar un gate. Si uno parece injusto, díselo a tu usuario o usa kj report-issue.",
|
|
@@ -24,13 +26,20 @@ process.stdin.on("data", (d) => { raw += d; });
|
|
|
24
26
|
process.stdin.on("end", () => {
|
|
25
27
|
try {
|
|
26
28
|
const { session_id: sid = "default", source } = JSON.parse(raw || "{}");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// KJC-TSK-0949: an MD changed and its compiled rules fell behind. Said at
|
|
30
|
+
// every start, a new session included: that one gets this line and nothing else.
|
|
31
|
+
const drift = rulesDriftNotice({ root: ROOT, run: spawnSync });
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (source === "compact" || source === "resume") {
|
|
34
|
+
const branch = branchOf() || "?";
|
|
35
|
+
const card = CARD.exec(branch)?.[0]?.toUpperCase() ?? "ninguna en la rama";
|
|
36
|
+
const pending = pendingMoves(load().sessions?.[sid]).map((p) => `${p.card ?? "?"} (PR #${p.pr})`);
|
|
37
|
+
const unmoved = pending.length ? "; cards mergeadas sin mover: " + pending.join(", ") : "";
|
|
38
|
+
lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
|
|
39
|
+
}
|
|
40
|
+
if (drift) lines.push(`Karajan: ${drift}`);
|
|
41
|
+
if (lines.length === 0) process.exit(0);
|
|
42
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
|
|
34
43
|
} catch { /* never fails a session */ }
|
|
35
44
|
process.exit(0);
|
|
36
45
|
});
|