karajan-code 4.39.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/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/init.js +6 -2
- 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/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 +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/config-resolver.js +19 -1
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -106,5 +106,22 @@ export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) =
|
|
|
106
106
|
return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
|
|
107
107
|
}));
|
|
108
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
|
+
};
|
|
125
|
+
|
|
109
126
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
110
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
|
});
|
|
@@ -92,11 +92,22 @@ export const boardGate = (sid, pend) => {
|
|
|
92
92
|
}
|
|
93
93
|
return { blocking: pend, carried: [] };
|
|
94
94
|
};
|
|
95
|
+
// KJC-BUG-0265: on the base branch what counts is work still there (the session's
|
|
96
|
+
// files dirty, or local commits the remote does not have), not the ledger: after a
|
|
97
|
+
// merged PR and a sync of main the ledger stays full while the base holds nothing.
|
|
98
|
+
// Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
|
|
99
|
+
const baseHoldsWork = (files) => {
|
|
100
|
+
if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
|
|
101
|
+
try {
|
|
102
|
+
if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
|
|
103
|
+
return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
|
|
104
|
+
} catch { return true; }
|
|
105
|
+
};
|
|
95
106
|
export const violations = (s, branch, pending = pendingMoves(s)) => {
|
|
96
107
|
const v = pending.map(pendingText);
|
|
97
108
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
98
109
|
if (bootstrapPhase()) return v;
|
|
99
|
-
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
110
|
+
if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
|
|
100
111
|
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)");
|
|
101
112
|
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");
|
|
102
113
|
return v;
|
|
@@ -492,7 +503,9 @@ process.stdin.on("end", () => {
|
|
|
492
503
|
}
|
|
493
504
|
// KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
|
|
494
505
|
// like the supervisor: a session does not loosen the rules that govern it.
|
|
495
|
-
|
|
506
|
+
// .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
|
|
507
|
+
// .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
|
|
508
|
+
if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
|
|
496
509
|
console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
|
|
497
510
|
process.exit(2);
|
|
498
511
|
}
|
|
@@ -1127,6 +1140,9 @@ const SCRIPT_BODIES = {
|
|
|
1127
1140
|
"sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
|
|
1128
1141
|
"sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
|
|
1129
1142
|
"sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
|
|
1143
|
+
// KJC-TSK-0946 (ADR 0016): the rules gate and its hook, for every tool call.
|
|
1144
|
+
"sentinel-rules.mjs": readFileSync(new URL("./sentinel/sentinel-rules.mjs", import.meta.url), "utf8"),
|
|
1145
|
+
"pretooluse-rules.mjs": readFileSync(new URL("./sentinel/pretooluse-rules.mjs", import.meta.url), "utf8"),
|
|
1130
1146
|
};
|
|
1131
1147
|
|
|
1132
1148
|
/**
|
|
@@ -1316,6 +1332,9 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1316
1332
|
entries: [
|
|
1317
1333
|
{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
|
|
1318
1334
|
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
|
|
1335
|
+
// KJC-TSK-0946 (ADR 0016): no matcher. The rules of the MD files are checked
|
|
1336
|
+
// before EVERY tool call, MCP ones included.
|
|
1337
|
+
{ event: "PreToolUse", script: "pretooluse-rules.mjs" },
|
|
1319
1338
|
{ event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
|
|
1320
1339
|
// KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
|
|
1321
1340
|
// update_card) are observed AFTER the fact — found live: with only the
|
|
@@ -1329,6 +1348,8 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1329
1348
|
// KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
|
|
1330
1349
|
{ event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
|
|
1331
1350
|
{ event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
|
|
1351
|
+
// KJC-TSK-0949: a new session hears of MD rules with no gate, and nothing else.
|
|
1352
|
+
{ event: "SessionStart", matcher: "startup", script: "sessionstart.mjs" },
|
|
1332
1353
|
],
|
|
1333
1354
|
});
|
|
1334
1355
|
return { scripts: [lib, post, stop, pre], wired, deferred };
|