karajan-code 4.39.0 → 4.41.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/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/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/commands/bootstrap.js +12 -41
- package/src/commands/env.js +8 -0
- package/src/commands/init.js +6 -2
- package/src/commands/rules-approve.js +63 -0
- package/src/commands/rules-compile.js +117 -0
- package/src/commands/rules-decide.js +52 -0
- package/src/commands/rules-review.js +57 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/environment/contract-commit.js +79 -0
- package/src/harden/config-templates.js +3 -0
- 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 +4 -2
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +17 -0
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +23 -2
- 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 +12 -2
- package/src/privacy/scan.js +7 -0
- package/src/review/gate-gitignore.js +8 -0
- package/src/rules/approval-view.js +54 -0
- package/src/rules/compiled.js +111 -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/config-resolver.js +19 -1
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules review (KJC-TSK-0963, MDR-F4, ADR 0017): the agent the rules will
|
|
3
|
+
* watch wrote the proposal, so it is not the one who calls it good. A DIFFERENT
|
|
4
|
+
* AI reads it rule by rule, and its verdict is tied to the exact bytes of the
|
|
5
|
+
* proposal (the same store and the same primitive as `kj review`). Without an
|
|
6
|
+
* approved verdict for those bytes, `kj rules approve` does not offer it.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
|
+
import { PROPOSAL_FILE, rulesCheck } from "./rules.js";
|
|
14
|
+
|
|
15
|
+
const TASK = [
|
|
16
|
+
"This is NOT code: it is a proposal of compiled rules (YAML). Each entry carries the literal `text` of a rule the user",
|
|
17
|
+
"wrote in their MD files (already verified word for word) and what an agent compiled it into. That agent is the one these",
|
|
18
|
+
"rules are going to watch, so it gains from a compilation that never fires. Judge every rule on one question: does the",
|
|
19
|
+
"compilation enforce what the text says?",
|
|
20
|
+
"- kind deterministic: `when` must deny the tool calls the text forbids. Too narrow a condition (it would rarely fire), or",
|
|
21
|
+
" examples picked so that a weak condition passes, is a defect. So is a condition so wide that it denies honest calls.",
|
|
22
|
+
"- kind judgment or out-of-scope: nothing is blocked on these. If a condition over a tool name and its arguments could",
|
|
23
|
+
" have enforced the text, the rule has been weakened.",
|
|
24
|
+
"Any rule may give a `reason` for how it is compiled: weigh it, do not take it on trust.",
|
|
25
|
+
"Know what a condition can see before you ask for one (KJC-TSK-0973): the tool name and the arguments of ONE call, with",
|
|
26
|
+
"the operators equals, in, matches, exists, gt, lt. It cannot read a file a command names (a commit message passed with -F,",
|
|
27
|
+
"a body file), earlier calls, or the state of the repo. A rule whose breach is only visible there cannot be deterministic,",
|
|
28
|
+
"and judgment is its honest kind. A condition that would deny honest calls (a word that is also ordinary text, a method",
|
|
29
|
+
"name, a comment) is a defect too: do not ask for one, and do not ask for more than the text forbids.",
|
|
30
|
+
"A deterministic rule that enforces every part of its text a condition CAN see is not weak for leaving out the part no",
|
|
31
|
+
"condition can see: block it only if the visible part is compiled narrower than it could be.",
|
|
32
|
+
"What blocks is MATERIAL weakness: a form of the forbidden call an agent would plausibly write in ordinary work is let",
|
|
33
|
+
"through, or a call an agent would plausibly make in honest work is denied. A pattern over shell text is never complete,",
|
|
34
|
+
"and these gates exist for a rule forgotten or bent in passing; an agent contorting a command to dodge them is stopped at",
|
|
35
|
+
"the checkpoints it does not reach (git hooks, CI), not here. So an exotic form (a command inside a control structure, a",
|
|
36
|
+
"rare global option, unusual casing) is a non-blocking suggestion, not a blocking issue.",
|
|
37
|
+
"Report as a BLOCKING issue every rule materially weaker than its text, naming its id (R-...) and saying what would",
|
|
38
|
+
"enforce it. Approve when none is.",
|
|
39
|
+
].join("\n");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{projectDir: string, file?: string, home?: string, config: object, logger?: object, deps?: object}} opts
|
|
43
|
+
* `deps` are the seams of runOneShotReview (hostAgent, createAgentFn, detectAgents).
|
|
44
|
+
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function rulesReview({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), config, logger, deps = {} }) {
|
|
47
|
+
let text;
|
|
48
|
+
try { text = fs.readFileSync(path.resolve(projectDir, file), "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to review`] }; }
|
|
49
|
+
const checked = rulesCheck({ projectDir, home, text });
|
|
50
|
+
if (checked.code !== 0) return { code: 1, lines: checked.lines };
|
|
51
|
+
const record = await runOneShotReview({ diff: text, task: TASK, config, logger, projectDir, ...deps });
|
|
52
|
+
if (record.verdict === "approved") {
|
|
53
|
+
return { code: 0, lines: [`✓ APPROVED by ${record.reviewer}: ${record.summary || "the compilation enforces the rules as written"}`, "Now your user reads it and runs `kj rules approve` from their own terminal."] };
|
|
54
|
+
}
|
|
55
|
+
const issues = record.issues.map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
|
|
56
|
+
return { code: 1, lines: [`✗ REJECTED by ${record.reviewer} — ${issues.length} rule(s) weaker than their text:`, ...issues, "Fix the proposal and run `kj rules review` again: the verdict is tied to its exact content."] };
|
|
57
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules eval (KJC-TSK-0943, MDR-B2, ADR 0016): the hook's contract, the same
|
|
3
|
+
* as `kj policy eval --strict`. One tool call in, a JSON verdict out, exit
|
|
4
|
+
* 0 = allow, 2 = deny, 1 = it could not be evaluated (invalid or unreadable
|
|
5
|
+
* rules.yml, unreadable input).
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { isObject, parseRules } from "../rules/compiled.js";
|
|
12
|
+
import { coverage, NO_GATE } from "../rules/coverage.js";
|
|
13
|
+
import { evalRules } from "../rules/evaluate.js";
|
|
14
|
+
import { inProject, listRules, shownSource } from "../rules/inventory.js";
|
|
15
|
+
|
|
16
|
+
export const RULES_FILE = path.join(".karajan", "rules.yml");
|
|
17
|
+
/** ADR 0017: rules whose source is outside the project. Never versioned. */
|
|
18
|
+
export const LOCAL_RULES_FILE = path.join(".karajan", "rules.local.yml");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The compiled rules of a project. A file that does not exist is no rules; a
|
|
22
|
+
* file that exists and cannot be read is an error (rules silently off would be
|
|
23
|
+
* every call allowed).
|
|
24
|
+
*/
|
|
25
|
+
function loadRulesFile(projectDir, file) {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = fs.readFileSync(path.resolve(projectDir, file), "utf8");
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === "ENOENT") return { present: false, rules: [], errors: [] };
|
|
31
|
+
return { present: true, rules: [], errors: [`cannot read ${file}: ${err.code || err.message}`] };
|
|
32
|
+
}
|
|
33
|
+
return { present: true, ...parseRules(text) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* With a `file`, that file alone (a proposal). Without one, what governs the
|
|
38
|
+
* project (KJC-TSK-0954, ADR 0017): the versioned rules and the local ones, as
|
|
39
|
+
* one rule set. An error in either leaves no rules; a rule lives in one file.
|
|
40
|
+
*/
|
|
41
|
+
export function loadRules(projectDir, file) {
|
|
42
|
+
if (file) return loadRulesFile(projectDir, file);
|
|
43
|
+
const loaded = [RULES_FILE, LOCAL_RULES_FILE].map((name) => ({ name, ...loadRulesFile(projectDir, name) }));
|
|
44
|
+
const errors = loaded.flatMap(({ name, errors: found }) => found.map((e) => `${name}: ${e}`));
|
|
45
|
+
const rules = loaded.flatMap((part) => part.rules);
|
|
46
|
+
const ids = rules.map((rule) => rule.id);
|
|
47
|
+
errors.push(...new Set(ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `${id}: in both ${RULES_FILE} and ${LOCAL_RULES_FILE}, a rule lives in one file`)));
|
|
48
|
+
return { present: loaded.some((part) => part.present), rules: errors.length ? [] : rules, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const failed = (...errors) => ({ code: 1, output: { errors } });
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* KJC-TSK-0946: `--input -` reads the tool input from stdin. The hook passes it
|
|
55
|
+
* that way, because a large Write does not fit in one argument. Read as a
|
|
56
|
+
* stream: a synchronous read of a pipe fails (EAGAIN) once the input is large.
|
|
57
|
+
*/
|
|
58
|
+
export async function readToolInput(flag, stdin = process.stdin) {
|
|
59
|
+
if (flag !== "-") return flag;
|
|
60
|
+
let text = "";
|
|
61
|
+
for await (const chunk of stdin) text += chunk;
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {{code: 0|1|2, output: object}} */
|
|
66
|
+
export function rulesEval({ projectDir, tool, input = "{}" }) {
|
|
67
|
+
if (typeof tool !== "string" || !tool) return failed("--tool is required");
|
|
68
|
+
let args;
|
|
69
|
+
try { args = JSON.parse(input); } catch { return failed("--input must be valid JSON"); }
|
|
70
|
+
if (!isObject(args)) return failed("--input must be a JSON object (the tool input)");
|
|
71
|
+
const { rules, errors } = loadRules(projectDir);
|
|
72
|
+
if (errors.length) return failed(...errors);
|
|
73
|
+
const output = evalRules(rules, { tool, input: args });
|
|
74
|
+
return { code: output.decision === "deny" ? 2 : 0, output };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isCall = (c) => isObject(c) && typeof c.tool === "string" && c.tool !== "" && (c.input === undefined || isObject(c.input));
|
|
78
|
+
const isCallList = (list) => Array.isArray(list) && list.length > 0 && list.every(isCall);
|
|
79
|
+
|
|
80
|
+
/** What a rule's examples say against the rule itself, alone. */
|
|
81
|
+
function exampleFailures(rule) {
|
|
82
|
+
const { deny, allow } = isObject(rule.examples) ? rule.examples : {};
|
|
83
|
+
if (!isCallList(deny) || !isCallList(allow)) return [`${rule.id}: needs examples.deny and examples.allow, each a list of { tool, input } calls`];
|
|
84
|
+
const verdict = (call) => evalRules([rule], call).decision;
|
|
85
|
+
return [
|
|
86
|
+
...deny.flatMap((call, i) => (verdict(call) === "deny" ? [] : [`${rule.id}: deny example #${i + 1} was allowed`])),
|
|
87
|
+
...allow.flatMap((call, i) => (verdict(call) === "deny" ? [`${rule.id}: allow example #${i + 1} was denied`] : [])),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* kj rules test (KJC-TSK-0945, MDR-B3): every deterministic rule against its own
|
|
93
|
+
* deny/allow examples. A rule with no examples is untested, and that fails too.
|
|
94
|
+
* @returns {{code: 0|1, lines: string[]}}
|
|
95
|
+
*/
|
|
96
|
+
export function rulesTest({ projectDir }) {
|
|
97
|
+
const { present, rules, errors } = loadRules(projectDir);
|
|
98
|
+
if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
|
|
99
|
+
if (!present) return { code: 0, lines: [`no ${RULES_FILE}: nothing to test`] };
|
|
100
|
+
const tested = rules.filter((rule) => rule.kind === "deterministic");
|
|
101
|
+
const failures = tested.flatMap(exampleFailures);
|
|
102
|
+
if (failures.length) return { code: 1, lines: failures.map((f) => `✗ ${f}`) };
|
|
103
|
+
const examples = tested.reduce((n, rule) => n + rule.examples.deny.length + rule.examples.allow.length, 0);
|
|
104
|
+
return { code: 0, lines: [`✓ ${tested.length} rule(s), ${examples} example(s)`] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const PROPOSAL_FILE = path.join(".karajan", "rules.proposed.yml");
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a proposed rule says against the MD files: it is one of their rules, word
|
|
111
|
+
* for word, and its source is where they write it (KJC-TSK-0961: the source
|
|
112
|
+
* decides whether a rule is versioned, so the proposal does not get to choose it).
|
|
113
|
+
* @param {object} rule
|
|
114
|
+
* @param {object[]|undefined} written every place the inventory found this rule
|
|
115
|
+
* @param {(file: string) => string} shown
|
|
116
|
+
*/
|
|
117
|
+
function inventoryFailures(rule, written, shown) {
|
|
118
|
+
if (!written) return [`${rule.id}: no rule of the MD files has this id (kj rules list)`];
|
|
119
|
+
const [first] = written;
|
|
120
|
+
if (rule.text !== first.text) return [`${rule.id}: text is not what the MD says (${first.file}:${first.line}): ${first.text}`];
|
|
121
|
+
const sources = written.map((found) => shown(found.file));
|
|
122
|
+
return sources.includes(rule.source) ? [] : [`${rule.id}: source is not where the MD files write it (${sources.join(", ")})`];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* kj rules check (KJC-TSK-0950, MDR-D1): a proposal of compiled rules proves
|
|
127
|
+
* itself before a human reads it. Every rule is one of the MD files and cites
|
|
128
|
+
* its literal text; every deterministic one passes its own examples.
|
|
129
|
+
* With `text`, that content is checked and the file is not read again: whoever
|
|
130
|
+
* installs a proposal installs the bytes that were checked.
|
|
131
|
+
* `local` holds the ids written in no file of the project: those are not versioned.
|
|
132
|
+
* @returns {{code: 0|1, lines: string[], rules?: object[], local?: Set<string>}}
|
|
133
|
+
*/
|
|
134
|
+
export function rulesCheck({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), text }) {
|
|
135
|
+
const { present, rules, errors } = text === undefined ? loadRules(projectDir, file) : { present: true, ...parseRules(text) };
|
|
136
|
+
if (!present) return { code: 1, lines: [`✗ no ${file}: nothing to check`] };
|
|
137
|
+
if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
|
|
138
|
+
const inventory = Map.groupBy(listRules(projectDir, { home }), (rule) => rule.id);
|
|
139
|
+
const shown = (found) => shownSource(found, projectDir, home);
|
|
140
|
+
const failures = rules.flatMap((rule) => [
|
|
141
|
+
...inventoryFailures(rule, inventory.get(rule.id), shown),
|
|
142
|
+
...(rule.kind === "deterministic" ? exampleFailures(rule) : []),
|
|
143
|
+
]);
|
|
144
|
+
if (failures.length) return { code: 1, lines: failures.map((f) => `✗ ${f}`) };
|
|
145
|
+
const count = (kind) => rules.filter((rule) => rule.kind === kind).length;
|
|
146
|
+
const local = new Set(rules.filter((rule) => !inventory.get(rule.id).some((found) => inProject(found.file, projectDir))).map((rule) => rule.id));
|
|
147
|
+
return { code: 0, rules, local, lines: [`✓ ${rules.length} rule(s) hold: ${count("deterministic")} deterministic, ${count("judgment")} judgment, ${count("out-of-scope")} out of scope`] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* kj rules coverage (KJC-TSK-0941, MDR-E): every rule of the governing MD files
|
|
152
|
+
* against rules.yml. `strict` fails while a rule has no gate or a compiled rule
|
|
153
|
+
* is stale, so a rule nobody decided about cannot go unseen.
|
|
154
|
+
* @returns {{code: 0|1, lines: string[], output?: object}}
|
|
155
|
+
*/
|
|
156
|
+
export function rulesCoverage({ projectDir, strict = false, home }) {
|
|
157
|
+
const { rules, errors } = loadRules(projectDir);
|
|
158
|
+
if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
|
|
159
|
+
const { rows, stale } = coverage(listRules(projectDir, home ? { home } : {}), rules);
|
|
160
|
+
const count = (status) => rows.filter((row) => row.status === status).length;
|
|
161
|
+
const counts = { deterministic: count("deterministic"), judgment: count("judgment"), "out-of-scope": count("out-of-scope"), [NO_GATE]: count(NO_GATE), stale: stale.length };
|
|
162
|
+
const ungated = rows.filter((row) => row.status === NO_GATE);
|
|
163
|
+
const lines = [
|
|
164
|
+
...ungated.map((r) => `✗ no gate ${r.id} ${r.file}:${r.line} ${r.text}`),
|
|
165
|
+
...stale.map((r) => `✗ stale ${r.id} ${r.source ?? "?"} ${r.text ?? ""} (its text is in no MD any more: compile it again)`),
|
|
166
|
+
`${rows.length} rule(s): ${counts.deterministic} deterministic, ${counts.judgment} judgment, ${counts["out-of-scope"]} out of scope, ${ungated.length} with no gate; ${stale.length} stale`,
|
|
167
|
+
];
|
|
168
|
+
return { code: strict && ungated.length + stale.length > 0 ? 1 : 0, lines, output: { counts, rows, stale } };
|
|
169
|
+
}
|
package/src/config/loader.js
CHANGED
|
@@ -194,10 +194,32 @@ function stripRuntimeOnlyKeys(config) {
|
|
|
194
194
|
return out;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// KJC-BUG-0253: a project's .karajan/kj.config.yml is versioned with the repo,
|
|
198
|
+
// so the SonarQube credentials never go there; they live in the global config
|
|
199
|
+
// (~/.karajan), where the token bootstrap already saves them.
|
|
200
|
+
const SECRET_KEYS = ["token", "admin_password"];
|
|
201
|
+
const isProjectConfig = (configPath) =>
|
|
202
|
+
path.basename(configPath) === "kj.config.yml"
|
|
203
|
+
&& path.basename(path.dirname(configPath)) === ".karajan"
|
|
204
|
+
&& path.resolve(configPath) !== path.resolve(getConfigPath());
|
|
205
|
+
|
|
206
|
+
function stripSecrets(config) {
|
|
207
|
+
if (!config?.sonarqube || typeof config.sonarqube !== "object") return { out: config, stripped: [] };
|
|
208
|
+
const stripped = SECRET_KEYS.filter((k) => config.sonarqube[k] != null);
|
|
209
|
+
if (!stripped.length) return { out: config, stripped };
|
|
210
|
+
const sonarqube = { ...config.sonarqube };
|
|
211
|
+
for (const k of stripped) delete sonarqube[k];
|
|
212
|
+
return { out: { ...config, sonarqube }, stripped: stripped.map((k) => `sonarqube.${k}`) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @returns {Promise<{strippedSecrets: string[]}>} the secret keys kept out of a project config */
|
|
197
216
|
export async function writeConfig(configPath, config) {
|
|
198
217
|
await ensureDir(path.dirname(configPath));
|
|
199
|
-
|
|
218
|
+
let sanitized = stripRuntimeOnlyKeys(config);
|
|
219
|
+
let strippedSecrets = [];
|
|
220
|
+
if (isProjectConfig(configPath)) ({ out: sanitized, stripped: strippedSecrets } = stripSecrets(sanitized));
|
|
200
221
|
await fs.writeFile(configPath, yaml.dump(sanitized, { lineWidth: 120 }), "utf8");
|
|
222
|
+
return { strippedSecrets };
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
// Declarative mappings for applyRunOverrides to reduce cognitive complexity.
|
package/src/environment/adr.js
CHANGED
|
@@ -8,6 +8,9 @@ import fs from "node:fs/promises";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
|
|
10
10
|
const ADR_DIR = path.join(".karajan", "adrs");
|
|
11
|
+
// KJC-BUG-0272: whoever records a decision proposes it. Accepting is the user's:
|
|
12
|
+
// they set `Status: accepted` in the file. Born accepted, nobody had decided.
|
|
13
|
+
const NEW_STATUS = "proposed";
|
|
11
14
|
|
|
12
15
|
const slugify = (t) => t.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "").slice(0, 60);
|
|
13
16
|
|
|
@@ -34,12 +37,12 @@ export async function addAdr(projectDir, { title, decision, context = "", conseq
|
|
|
34
37
|
const file = path.join(ADR_DIR, `${String(number).padStart(4, "0")}-${slugify(title)}.md`);
|
|
35
38
|
const body = [
|
|
36
39
|
`# ${title}`, "",
|
|
37
|
-
`Status:
|
|
40
|
+
`Status: ${NEW_STATUS}`, `Date: ${new Date().toISOString().slice(0, 10)}`, "",
|
|
38
41
|
...(context ? ["## Context", "", context, ""] : []),
|
|
39
42
|
"## Decision", "", decision, "",
|
|
40
43
|
...(consequences ? ["## Consequences", "", consequences, ""] : []),
|
|
41
44
|
].join("\n");
|
|
42
45
|
await fs.mkdir(path.join(projectDir, ADR_DIR), { recursive: true });
|
|
43
46
|
await fs.writeFile(path.join(projectDir, file), body);
|
|
44
|
-
return { number, file, title };
|
|
47
|
+
return { number, file, title, status: NEW_STATUS };
|
|
45
48
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract commit (KJC-TSK-0857, KJC-BUG-0273). What kj generates for a
|
|
3
|
+
* project (playbook, skills, settings, workflows, gate marker, hooks) the whole
|
|
4
|
+
* team must inherit by cloning, so it goes into git. Nobody wrote it, and an
|
|
5
|
+
* agent's own PR-size rules forbid a 1200-line commit, so kj makes the commit
|
|
6
|
+
* itself: only the files it just generated, never what was already dirty, and
|
|
7
|
+
* never on the base branch once the repo has history. This is NOT the supervisor
|
|
8
|
+
* seal (ADR 0009), which stays a human act.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
/** What kj generates and the whole team must inherit by cloning (prefixes). */
|
|
13
|
+
export const CONTRACT_PATHS = [
|
|
14
|
+
".gitignore",
|
|
15
|
+
".karajan/hooks/",
|
|
16
|
+
".karajan/review-gate",
|
|
17
|
+
".karajan/adrs/",
|
|
18
|
+
".karajan/policy.yml",
|
|
19
|
+
".claude/",
|
|
20
|
+
".github/workflows/kj-",
|
|
21
|
+
"CLAUDE.md",
|
|
22
|
+
"AGENTS.md",
|
|
23
|
+
"GEMINI.md",
|
|
24
|
+
// KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
|
|
25
|
+
".rulesync/",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const FRESH_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
|
|
29
|
+
const REGEN_MESSAGE = "chore(kj): el contrato del método, generado por kj";
|
|
30
|
+
|
|
31
|
+
const runner = (projectDir, env) => (args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
|
|
32
|
+
const isContract = (file) => CONTRACT_PATHS.some((p) => (p.endsWith("/") || p.endsWith("-") ? file.startsWith(p) : file === p));
|
|
33
|
+
|
|
34
|
+
/** The contract files git sees as changed, with their porcelain code (`??` untracked). */
|
|
35
|
+
function contractStatus(git) {
|
|
36
|
+
try {
|
|
37
|
+
const lines = git(["status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
|
|
38
|
+
return lines.map((line) => [line.slice(3).trim().split(" -> ").at(-1), line.slice(0, 2)]).filter(([file]) => isContract(file));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The contract files that differ from HEAD (or are untracked), one by one.
|
|
46
|
+
* @returns {Set<string>} empty when git cannot answer
|
|
47
|
+
*/
|
|
48
|
+
export function contractChanges(projectDir, git = runner(projectDir, process.env)) {
|
|
49
|
+
return new Set(contractStatus(git).map(([file]) => file));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hasCommits = (git) => { try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; } };
|
|
53
|
+
const branchOf = (git) => { try { return git(["symbolic-ref", "--short", "HEAD"]).trim(); } catch { return ""; } };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{projectDir: string, before?: Set<string>, baseBranch?: string, env?: object}} opts
|
|
57
|
+
* `before`: the contract files already dirty BEFORE kj generated anything,
|
|
58
|
+
* tracked or not. They may carry the person's words, so they stay out.
|
|
59
|
+
* @returns {{committed: boolean, files?: string[], reason?: string}}
|
|
60
|
+
*/
|
|
61
|
+
export function commitContract({ projectDir, before = new Set(), baseBranch = "main", env = process.env }) {
|
|
62
|
+
const git = runner(projectDir, env);
|
|
63
|
+
const files = contractStatus(git).map(([file]) => file).filter((file) => !before.has(file)).sort();
|
|
64
|
+
if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
|
|
65
|
+
const history = hasCommits(git);
|
|
66
|
+
if (history && branchOf(git) === baseBranch) {
|
|
67
|
+
// kj never commits on the base branch, and will not switch the person's branch
|
|
68
|
+
// for them: the files are named so the commit can be made where it belongs.
|
|
69
|
+
return { committed: false, files, reason: `on the base branch '${baseBranch}', where kj never commits: create a branch and commit these generated files there (git checkout -b chore/kj-contract && git add -- ${files.join(" ")} && git commit -m "chore(kj): el contrato del método")` };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
git(["add", "--", ...files]);
|
|
73
|
+
// --only: these paths and nothing else. What the person had staged stays staged.
|
|
74
|
+
git(["commit", "--only", "-m", history ? REGEN_MESSAGE : FRESH_MESSAGE, "--", ...files]);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { committed: false, files, reason: `git could not commit the contract: ${String(err.stderr || err.message).trim().split("\n")[0]}` };
|
|
77
|
+
}
|
|
78
|
+
return { committed: true, files };
|
|
79
|
+
}
|
|
@@ -42,6 +42,9 @@ export const COMMITLINT_BODY = [
|
|
|
42
42
|
// project has no eslint config of its own.
|
|
43
43
|
export const ESLINT_BODY = [
|
|
44
44
|
"export default [",
|
|
45
|
+
// KJC-BUG-0254 (#1902): third-party and generated code is not the project's to
|
|
46
|
+
// lint (a vendored tf.min.js gave 6607 false no-var errors).
|
|
47
|
+
' { ignores: ["vendor/**", "dist/**", "build/**", "coverage/**", ".scannerwork/**", "**/*.min.{js,mjs,cjs}"] },',
|
|
45
48
|
" {",
|
|
46
49
|
' files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],',
|
|
47
50
|
' languageOptions: { ecmaVersion: 2025, sourceType: "module" },',
|
|
@@ -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)];
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
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 };
|
|
@@ -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
|
+
}
|