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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules compile (KJC-TSK-0940, MDR-D, ADR 0016): the brief for whoever
|
|
3
|
+
* compiles the rules of the MD files that have no gate yet. kj calls no model:
|
|
4
|
+
* the host agent knows its own tools and their arguments, which is what a
|
|
5
|
+
* condition has to name. It proposes; `kj rules check` proves the proposal and
|
|
6
|
+
* the user approves it (`kj rules approve`, a human act).
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import yaml from "js-yaml";
|
|
13
|
+
|
|
14
|
+
import { shownSource } from "../rules/inventory.js";
|
|
15
|
+
import { loadRules, PROPOSAL_FILE, RULES_FILE, rulesCoverage } from "./rules.js";
|
|
16
|
+
|
|
17
|
+
const FORMAT = `\`\`\`yaml
|
|
18
|
+
version: 1
|
|
19
|
+
rules:
|
|
20
|
+
- id: R-0a1b2c3d4e # the id below, as is
|
|
21
|
+
source: CLAUDE.md # the MD file the rule is written in
|
|
22
|
+
text: "Sprints: uno por día." # the rule's text, word for word
|
|
23
|
+
kind: deterministic
|
|
24
|
+
when: # WHEN TO DENY the call
|
|
25
|
+
tool: "mcp__planning-game*__create_sprint" # a glob, or a list of globs
|
|
26
|
+
any: # all: every condition holds; any: at least one
|
|
27
|
+
- { arg: allowLongSprint, equals: true }
|
|
28
|
+
- { arg: endDate, days_from: startDate, gt: 0 }
|
|
29
|
+
message: "Un sprint dura un día." # what the denied agent reads
|
|
30
|
+
examples: # the rule is run against them: both lists required
|
|
31
|
+
deny: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-11" } }]
|
|
32
|
+
allow: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-05" } }]
|
|
33
|
+
- { id: R-1a2b3c4d5e, source: CLAUDE.md, text: "No preguntes obviedades.", kind: judgment, when: { tool: AskUserQuestion } }
|
|
34
|
+
- { id: R-2a3b4c5d6e, source: CLAUDE.md, text: "Habla español correcto.", kind: out-of-scope, reason: "no tool call breaks it" }
|
|
35
|
+
\`\`\``;
|
|
36
|
+
|
|
37
|
+
const HOW = [
|
|
38
|
+
"A condition reads ONE argument of the tool input (`arg`, a dotted path such as updates.status) with exactly one",
|
|
39
|
+
"operator: equals, in (a list), matches (a regex), exists (true/false), gt, lt. With days_from, gt/lt compare the",
|
|
40
|
+
"calendar days from that other argument to `arg`. There is nothing else: no other keys, no free code.",
|
|
41
|
+
"",
|
|
42
|
+
"Choose the kind of each rule:",
|
|
43
|
+
"- deterministic: ONE tool call breaks it and the tool name and its arguments are enough to tell. Name the tools",
|
|
44
|
+
" as you see them (MCP tools included). Deny only what the rule forbids: a gate that denies honest calls gets removed.",
|
|
45
|
+
"- judgment: a tool call breaks it, but telling takes reading what the call says. Give `when.tool` only.",
|
|
46
|
+
"- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
|
|
47
|
+
"Do not stretch a rule into a condition it does not state, and do not leave a rule out: every rule gets a kind.",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* KJC-TSK-0953: kj writes the skeleton of the proposal, so nobody retypes the
|
|
52
|
+
* literal texts. It starts from what rules.yml holds minus the stale, lays the
|
|
53
|
+
* proposal already there over it (what is filled in stays), and adds one entry
|
|
54
|
+
* per pending rule that is missing, with no kind yet.
|
|
55
|
+
* @returns {number|null} the entries with no kind, or null when the proposal there is not YAML
|
|
56
|
+
*/
|
|
57
|
+
function writeSkeleton({ projectDir, home, pending, kept, gone }) {
|
|
58
|
+
const file = path.join(projectDir, PROPOSAL_FILE);
|
|
59
|
+
let proposed = [];
|
|
60
|
+
if (fs.existsSync(file)) {
|
|
61
|
+
try { proposed = yaml.load(fs.readFileSync(file, "utf8"))?.rules; } catch { return null; }
|
|
62
|
+
if (!Array.isArray(proposed)) return null;
|
|
63
|
+
}
|
|
64
|
+
// The approved rules are the base: a proposal that forgot one would remove its
|
|
65
|
+
// gate on approval. What the proposal says about a rule wins; the stale leave.
|
|
66
|
+
const byId = new Map(kept.map((rule) => [rule.id, rule]));
|
|
67
|
+
const loose = []; // entries with no usable id: kept, for kj rules check to name
|
|
68
|
+
for (const entry of proposed) {
|
|
69
|
+
if (typeof entry?.id !== "string") loose.push(entry);
|
|
70
|
+
else if (!gone.has(entry.id)) byId.set(entry.id, entry);
|
|
71
|
+
}
|
|
72
|
+
for (const rule of pending) {
|
|
73
|
+
if (!byId.has(rule.id)) byId.set(rule.id, { id: rule.id, source: shownSource(rule.file, projectDir, home), text: rule.text });
|
|
74
|
+
}
|
|
75
|
+
const rules = [...byId.values(), ...loose];
|
|
76
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
77
|
+
fs.writeFileSync(file, yaml.dump({ version: 1, rules }, { lineWidth: -1 }));
|
|
78
|
+
return rules.filter((entry) => !entry?.kind).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {{code: 0|1, lines: string[]}} */
|
|
82
|
+
export function rulesCompileBrief({ projectDir, home = os.homedir() }) {
|
|
83
|
+
const covered = rulesCoverage({ projectDir, home });
|
|
84
|
+
if (!covered.output) return covered;
|
|
85
|
+
const pending = covered.output.rows.filter((row) => row.status === "none");
|
|
86
|
+
const { stale } = covered.output;
|
|
87
|
+
if (pending.length + stale.length === 0) return { code: 0, lines: ["✓ every rule of the MD files is decided: nothing to compile"] };
|
|
88
|
+
const gone = new Set(stale.map((rule) => rule.id));
|
|
89
|
+
const kept = loadRules(projectDir).rules.filter((rule) => !gone.has(rule.id));
|
|
90
|
+
const undecided = writeSkeleton({ projectDir, home, pending, kept, gone });
|
|
91
|
+
if (undecided === null) return { code: 1, lines: [`✗ ${PROPOSAL_FILE} is not valid YAML with a rules list: fix it or delete it`] };
|
|
92
|
+
return {
|
|
93
|
+
code: 0,
|
|
94
|
+
lines: [
|
|
95
|
+
"# Compile the rules of the MD files into gates (ADR 0016)",
|
|
96
|
+
"",
|
|
97
|
+
`${PROPOSAL_FILE} is written: what ${RULES_FILE} holds today, as it is, and one entry per rule with no gate,`,
|
|
98
|
+
`with its id, source and literal text. ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet: edit each one, give it its kind and`,
|
|
99
|
+
`what that kind takes. Leave id, source and text as they are. You propose; you cannot write ${RULES_FILE}.`,
|
|
100
|
+
...(stale.length ? [`Left out, their text is in no MD any more: ${stale.map((rule) => rule.id).join(", ")}`] : []),
|
|
101
|
+
"",
|
|
102
|
+
FORMAT,
|
|
103
|
+
"",
|
|
104
|
+
...HOW,
|
|
105
|
+
"",
|
|
106
|
+
"A rule with a condition (deterministic) you write by hand, in its entry. The rest take no condition, and you",
|
|
107
|
+
"decide many at once: `kj rules decide <id>... --kind judgment --tool <tool>` or",
|
|
108
|
+
"`kj rules decide <id>... --kind out-of-scope --reason \"<why no tool call breaks it>\"`.",
|
|
109
|
+
"",
|
|
110
|
+
"## Then",
|
|
111
|
+
"Run `kj rules check` and fix the proposal until it holds. Then run `kj rules review`: a different AI judges",
|
|
112
|
+
"whether each compilation is as strong as its text, and a rule you weakened comes back to you. Only then ask your",
|
|
113
|
+
"user to read it and run `kj rules approve` from their own terminal: no agent session can.",
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules decide (KJC-TSK-0969, MDR-G, ADR 0016): a real project has more than
|
|
3
|
+
* a hundred rules, and most take no condition. Their kind is decided in one go:
|
|
4
|
+
* judgment with the tool it shows on, or out of scope with the reason. kj writes
|
|
5
|
+
* the proposal, as it writes its skeleton. A deterministic rule is not decided
|
|
6
|
+
* here: its condition and its examples are written rule by rule.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
|
|
13
|
+
import { PROPOSAL_FILE } from "./rules.js";
|
|
14
|
+
|
|
15
|
+
const failed = (line) => ({ code: 1, lines: [`✗ ${line}`] });
|
|
16
|
+
|
|
17
|
+
/** What each kind takes, or why it cannot be decided this way. */
|
|
18
|
+
function decision({ kind, tools = [], reason = "" }) {
|
|
19
|
+
if (kind === "judgment") {
|
|
20
|
+
if (tools.length === 0) return { error: "a judgment rule names the tool it shows on: --tool <glob> (repeatable)" };
|
|
21
|
+
return { fields: { kind, when: { tool: tools.length === 1 ? tools[0] : tools } } };
|
|
22
|
+
}
|
|
23
|
+
if (kind === "out-of-scope") {
|
|
24
|
+
if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
|
|
25
|
+
return { fields: { kind, reason: reason.trim() } };
|
|
26
|
+
}
|
|
27
|
+
return { error: "--kind is judgment or out-of-scope; a deterministic rule is written by hand, with its condition and its examples" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{projectDir: string, file?: string, ids: string[], kind: string, tools?: string[], reason?: string}} opts
|
|
32
|
+
* @returns {{code: 0|1, lines: string[]}}
|
|
33
|
+
*/
|
|
34
|
+
export function rulesDecide({ projectDir, file = PROPOSAL_FILE, ids, kind, tools, reason }) {
|
|
35
|
+
if (!Array.isArray(ids) || ids.length === 0) return failed("name at least one rule id (R-...)");
|
|
36
|
+
const { fields, error } = decision({ kind, tools, reason });
|
|
37
|
+
if (error) return failed(error);
|
|
38
|
+
const target = path.resolve(projectDir, file);
|
|
39
|
+
let doc;
|
|
40
|
+
try { doc = yaml.load(fs.readFileSync(target, "utf8")); } catch { return failed(`no readable ${file}: run \`kj rules compile\` first`); }
|
|
41
|
+
if (!Array.isArray(doc?.rules)) return failed(`${file} has no rules list`);
|
|
42
|
+
const wanted = new Set(ids);
|
|
43
|
+
const known = new Set(doc.rules.map((entry) => entry?.id));
|
|
44
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
45
|
+
if (missing.length) return failed(`not in ${file}: ${missing.join(", ")}`);
|
|
46
|
+
// What identifies the rule stays; what its previous kind took goes.
|
|
47
|
+
doc.rules = doc.rules.map((entry) => (wanted.has(entry?.id) ? { id: entry.id, source: entry.source, text: entry.text, ...fields } : entry));
|
|
48
|
+
fs.writeFileSync(target, yaml.dump(doc, { lineWidth: -1 }));
|
|
49
|
+
const undecided = doc.rules.filter((entry) => !entry?.kind).length;
|
|
50
|
+
return { code: 0, lines: [`✓ ${wanted.size} rule(s) decided as ${kind}; ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet`] };
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
"Report as a BLOCKING issue every rule that is weaker than its text, naming its id (R-...) and saying what would enforce it.",
|
|
25
|
+
"Approve only when no rule is weaker than its text.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{projectDir: string, file?: string, home?: string, config: object, logger?: object, deps?: object}} opts
|
|
30
|
+
* `deps` are the seams of runOneShotReview (hostAgent, createAgentFn, detectAgents).
|
|
31
|
+
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function rulesReview({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), config, logger, deps = {} }) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = fs.readFileSync(path.resolve(projectDir, file), "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to review`] }; }
|
|
36
|
+
const checked = rulesCheck({ projectDir, home, text });
|
|
37
|
+
if (checked.code !== 0) return { code: 1, lines: checked.lines };
|
|
38
|
+
const record = await runOneShotReview({ diff: text, task: TASK, config, logger, projectDir, ...deps });
|
|
39
|
+
if (record.verdict === "approved") {
|
|
40
|
+
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."] };
|
|
41
|
+
}
|
|
42
|
+
const issues = record.issues.map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
|
|
43
|
+
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."] };
|
|
44
|
+
}
|
|
@@ -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" },',
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Field case 2026-08-02: text rules were not enough — the environment must
|
|
4
4
|
* impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
|
|
5
5
|
* it into the project's `.claude/settings.json` (merged, never clobbered):
|
|
6
|
-
* Write over an existing file blocks ("use Edit",
|
|
7
|
-
* Bash
|
|
6
|
+
* Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
|
|
7
|
+
* Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
|
|
8
|
+
* replaced the JSON-reserialization guard that lived here.
|
|
8
9
|
* Claude-only v1 — the abstraction arrives with the second host that
|
|
9
10
|
* supports tool hooks.
|
|
10
11
|
*/
|
|
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
|
|
|
19
20
|
// so a gate bug never bricks the session.
|
|
20
21
|
import console from "node:console";
|
|
21
22
|
import process from "node:process";
|
|
22
|
-
import { existsSync
|
|
23
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
// El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
|
|
26
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
27
24
|
let raw = "";
|
|
28
25
|
process.stdin.on("data", (d) => { raw += d; });
|
|
29
26
|
process.stdin.on("end", () => {
|
|
30
27
|
try {
|
|
31
28
|
const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
32
|
-
if (tool === "Write" &&
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.exit(2);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (tool === "Bash") {
|
|
39
|
-
const cmd = String(input.command || "");
|
|
40
|
-
// KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
|
|
41
|
-
// resto de guardias. Mirando solo process.env el agente no tenia salida:
|
|
42
|
-
// un export dentro del comando jamas llega a este proceso.
|
|
43
|
-
// Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
|
|
44
|
-
// aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
|
|
45
|
-
// un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
|
|
46
|
-
const toks = cmd.trim().split(/\\s+/);
|
|
47
|
-
let i = 0;
|
|
48
|
-
let named = false;
|
|
49
|
-
while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
|
|
50
|
-
if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
|
|
51
|
-
i += 1;
|
|
52
|
-
}
|
|
53
|
-
// Una asignacion delante solo alcanza a SU comando, asi que el escape del
|
|
54
|
-
// comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
|
|
55
|
-
// o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
|
|
56
|
-
// que va entre comillas no encadena nada, asi que no cuenta.
|
|
57
|
-
// Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
|
|
58
|
-
// hereda la asignacion (catch de la review). Mismo criterio que el resto
|
|
59
|
-
// de guardias del Sentinel.
|
|
60
|
-
// Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
|
|
61
|
-
// encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
|
|
62
|
-
const noSingle = cmd.replaceAll(/'[^']*'/g, "");
|
|
63
|
-
const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
|
|
64
|
-
const bare = noSingle.replaceAll(/"[^"]*"/g, "");
|
|
65
|
-
const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
|
|
66
|
-
const cmdEscape = named && simple && i < toks.length;
|
|
67
|
-
const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
|
|
68
|
-
// Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
|
|
69
|
-
if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
|
|
70
|
-
const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
|
|
71
|
-
// KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
|
|
72
|
-
// temporal del scratchpad no es asunto de este guard, y bloquearlo
|
|
73
|
-
// ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
|
|
74
|
-
const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
|
|
75
|
-
// Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
|
|
76
|
-
// fuera del arbol. Una relativa depende del cwd del shell, que este hook
|
|
77
|
-
// no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
|
|
78
|
-
// dentro del repo (catch de la review), asi que se sigue vigilando.
|
|
79
|
-
// Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
|
|
80
|
-
// (catch de la review). Se resuelve el padre real antes de clasificar, y
|
|
81
|
-
// si no se puede resolver, se vigila.
|
|
82
|
-
// Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
|
|
83
|
-
// <repo>/..config.json esta dentro (catch de la review).
|
|
84
|
-
const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
|
|
85
|
-
const outsideForSure = (t) => {
|
|
86
|
-
if (!t.startsWith("/")) return false;
|
|
87
|
-
const abs = resolve(t);
|
|
88
|
-
try {
|
|
89
|
-
const root = realpathSync(ROOT);
|
|
90
|
-
// El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
|
|
91
|
-
// asi que se resuelve entero cuando existe; si aun no existe, manda su
|
|
92
|
-
// directorio real.
|
|
93
|
-
if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
|
|
94
|
-
const parts = abs.split("/");
|
|
95
|
-
const name = parts.pop();
|
|
96
|
-
return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
// Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
|
|
102
|
-
// deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
|
|
103
|
-
// de la review). Ante la duda se vigila.
|
|
104
|
-
const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
|
|
105
|
-
const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
|
|
106
|
-
if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
|
|
107
|
-
console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
|
|
108
|
-
process.exit(2);
|
|
109
|
-
}
|
|
29
|
+
if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
|
|
30
|
+
console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
|
|
31
|
+
process.exit(2);
|
|
110
32
|
}
|
|
111
33
|
} catch { /* fail open */ }
|
|
112
34
|
process.exit(0);
|
|
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
|
|
|
62
62
|
return [
|
|
63
63
|
"# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
|
|
64
64
|
"# the bootstrap commit is exempt, there is no commit to branch from yet.",
|
|
65
|
-
|
|
65
|
+
"# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
|
|
66
|
+
"if git rev-parse --verify HEAD >/dev/null 2>&1; then",
|
|
66
67
|
' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
67
68
|
` if [ "$current_branch" = "${baseBranch}" ]; then`,
|
|
68
|
-
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR
|
|
69
|
+
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
|
|
69
70
|
" fi",
|
|
70
71
|
"fi",
|
|
71
72
|
];
|
|
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
|
|
|
78
79
|
function identityGuard(hook) {
|
|
79
80
|
const head = [
|
|
80
81
|
"# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
|
|
81
|
-
|
|
82
|
+
"if [ -f .karajan/identity.local.yml ]; then",
|
|
82
83
|
];
|
|
83
84
|
const tail = [
|
|
84
85
|
"elif [ ! -f .karajan/identity.local.yml ]; then",
|
|
@@ -92,7 +93,7 @@ function identityGuard(hook) {
|
|
|
92
93
|
" kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
93
94
|
" kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
94
95
|
' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
|
|
95
|
-
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show
|
|
96
|
+
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
|
|
96
97
|
" fi",
|
|
97
98
|
...tail,
|
|
98
99
|
];
|
|
@@ -103,7 +104,7 @@ function identityGuard(hook) {
|
|
|
103
104
|
' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
|
|
104
105
|
" kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
|
|
105
106
|
' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
|
|
106
|
-
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh
|
|
107
|
+
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
|
|
107
108
|
" fi",
|
|
108
109
|
...tail,
|
|
109
110
|
];
|