karajan-code 4.55.0 → 4.57.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/scripts/osv-gate.mjs +45 -0
- package/src/audit/osv-findings.js +8 -0
- package/src/checks/release-check.js +7 -1
- package/src/cli/register-meta.js +2 -0
- package/src/cli/register-pipeline.js +8 -0
- package/src/commands/harden.js +5 -15
- package/src/commands/rag.js +1 -1
- package/src/commands/review-gate.js +14 -8
- package/src/commands/worktree.js +2 -1
- package/src/git/hu-automation.js +2 -2
- package/src/harden/check.js +14 -13
- package/src/harden/config-engine.js +34 -24
- package/src/harden/config-templates.js +17 -1
- package/src/harden/harden-scope.js +18 -0
- package/src/harden/hook-templates.js +24 -3
- package/src/harden/human-act.js +32 -9
- package/src/harden/sandbox.js +120 -0
- package/src/harden/sentinel/sentinel-shell.mjs +6 -5
- package/src/harden/sentinel-hooks.js +22 -87
- package/src/rag/indexer.js +2 -2
- package/src/review/one-shot-review.js +9 -3
- package/src/review/pr-requirements.js +87 -0
- package/src/review/pr-security.js +70 -0
- package/src/review/pr-sonar.js +22 -0
- package/src/review/sonar-pregate.js +7 -3
- package/src/review/tests-with-code.js +5 -2
- package/src/review/third-party-review.js +67 -0
- package/src/steward/gate.js +31 -0
- package/src/utils/git.js +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* osv-gate (KJC-TSK-1097): CI fails on any advisory osv-scanner scores 9 or more
|
|
4
|
+
* (critical), naming the lockfile, package, version and advisory. Output it cannot
|
|
5
|
+
* read fails too: a scanner that broke is not a clean scan.
|
|
6
|
+
*
|
|
7
|
+
* Usage: osv-scanner --format=json -r . > osv.json; node scripts/osv-gate.mjs osv.json
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { relative } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parseOsvOutput } from "../src/audit/osv-findings.js";
|
|
13
|
+
|
|
14
|
+
const CRITICAL = 9;
|
|
15
|
+
|
|
16
|
+
/** One line per critical package and advisory, paths relative to the repo root. */
|
|
17
|
+
export function criticalAdvisories(raw, root) {
|
|
18
|
+
const lines = parseOsvOutput(raw).vulnerabilities
|
|
19
|
+
.filter((v) => v.score !== null && v.score >= CRITICAL)
|
|
20
|
+
.map((v) => `${relative(root, v.source)}: ${v.package}@${v.version} ${v.id} (${v.score})`);
|
|
21
|
+
return [...new Set(lines)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The gate's verdict for the scanner's raw output. */
|
|
25
|
+
export function gateReport(text, root) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
raw = null;
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(raw?.results)) return { ok: false, message: "osv-gate: could not read the osv-scanner output (no results list)" };
|
|
33
|
+
const found = criticalAdvisories(raw, root);
|
|
34
|
+
if (found.length === 0) return { ok: true, message: "osv-gate: no critical advisories" };
|
|
35
|
+
const details = found.map((line) => "- " + line).join("\n");
|
|
36
|
+
return { ok: false, message: `osv-gate: ${found.length} critical advisory(ies):\n${details}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
40
|
+
const file = process.argv[2];
|
|
41
|
+
if (!file) throw new Error("osv-gate: pass the osv-scanner JSON file");
|
|
42
|
+
const { ok, message } = gateReport(readFileSync(file, "utf8"), process.cwd());
|
|
43
|
+
console.log(message);
|
|
44
|
+
process.exitCode = ok ? 0 : 1;
|
|
45
|
+
}
|
|
@@ -103,6 +103,7 @@ export function parseOsvOutput(raw) {
|
|
|
103
103
|
id: vuln.id || vuln.aliases?.[0] || "UNKNOWN",
|
|
104
104
|
aliases: vuln.aliases || [],
|
|
105
105
|
severity: extractSeverity(vuln, groups),
|
|
106
|
+
score: groupScore(vuln, groups),
|
|
106
107
|
summary: vuln.summary || vuln.details?.split("\n")[0] || "",
|
|
107
108
|
publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
|
|
108
109
|
package: name,
|
|
@@ -141,6 +142,13 @@ function extractSeverity(vuln, groups) {
|
|
|
141
142
|
return "UNKNOWN";
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** KJC-TSK-1097: the numeric CVSS score of the vuln's group (9 or more is critical), or null. */
|
|
146
|
+
function groupScore(vuln, groups) {
|
|
147
|
+
const group = groups.find((g) => g.ids?.includes(vuln.id) && g.max_severity);
|
|
148
|
+
const score = group ? Number.parseFloat(group.max_severity) : Number.NaN;
|
|
149
|
+
return Number.isNaN(score) ? null : score;
|
|
150
|
+
}
|
|
151
|
+
|
|
144
152
|
function extractFixedVersion(vuln) {
|
|
145
153
|
const ranges = vuln.affected?.[0]?.ranges || [];
|
|
146
154
|
for (const range of ranges) {
|
|
@@ -70,7 +70,13 @@ async function policyRangeCheck(projectDir) {
|
|
|
70
70
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
71
71
|
if (hard.length > 0) {
|
|
72
72
|
const list = hard.map((v) => `[${v.rule_id}]${v.file ? ` ${v.file}` : ""}`).join(", ");
|
|
73
|
-
|
|
73
|
+
// KJC-BUG-0374: supervisor files the seal does not back (an older kj's seal, or an
|
|
74
|
+
// unsealed edit) are fixed by regenerating and resealing, never by a tag or a grant.
|
|
75
|
+
const why = sup.note ? ` (${sup.note})` : "";
|
|
76
|
+
const reseal = hard.some((v) => v.rule_id === "defaults.supervisor.write")
|
|
77
|
+
? ` — the supervisor seal does not back these files${why}: run kj harden, then ask your user for kj harden --commit`
|
|
78
|
+
: "";
|
|
79
|
+
return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}${reseal}` };
|
|
74
80
|
}
|
|
75
81
|
return { name: "policy", ok: true, detail: `${scope} clean against the current policy (${violations.length} warning(s))${skipped}` };
|
|
76
82
|
} catch (err) {
|
package/src/cli/register-meta.js
CHANGED
|
@@ -1048,6 +1048,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
1048
1048
|
.option("--no-config", "Skip lint/format/commit config files (hooks only)")
|
|
1049
1049
|
.option("--no-ci", "Skip CI quality workflows")
|
|
1050
1050
|
.option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
|
|
1051
|
+
.option("--no-sandbox", "Leave the host Bash sandbox alone (by default it is turned on where it can start)")
|
|
1051
1052
|
.option("--mutation", "Also seed an opt-in nightly/manual mutation CI job (never a PR gate)")
|
|
1052
1053
|
.option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
|
|
1053
1054
|
.option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
|
|
@@ -1065,6 +1066,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
1065
1066
|
config: flags.config !== false,
|
|
1066
1067
|
ci: flags.ci !== false,
|
|
1067
1068
|
guidelines: flags.guidelines !== false,
|
|
1069
|
+
sandbox: flags.sandbox !== false,
|
|
1068
1070
|
mutation: Boolean(flags.mutation),
|
|
1069
1071
|
only: flags.only ?? [],
|
|
1070
1072
|
exclude: flags.exclude ?? [],
|
|
@@ -15,6 +15,7 @@ import { statusCommand } from "../commands/status.js";
|
|
|
15
15
|
import { loadPlan } from "../plan/plan-store.js";
|
|
16
16
|
import { withConfig } from "./_shared.js";
|
|
17
17
|
import { publishPrReview } from "../review/publish-pr.js";
|
|
18
|
+
import { thirdPartyReviewCommand } from "../review/third-party-review.js";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Register the "core pipeline" commands: everything the user runs day-to-day
|
|
@@ -257,8 +258,15 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
257
258
|
.option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
|
|
258
259
|
.option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
|
|
259
260
|
.option("--publish-pr <n>", "Publish the cross-AI verdicts of a PR's commits on it as one GitHub review")
|
|
261
|
+
.option("--third-party", "Review someone else's PR (--range base...head): reviewer only, no own-work gates, no verdict stored")
|
|
262
|
+
.option("--json", "With --third-party: print the review as one JSON document on stdout")
|
|
260
263
|
.action(async (task, flags) => {
|
|
261
264
|
await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
|
|
265
|
+
// KJC-TSK-1075 (#2227): a third-party PR, for reviewer agents; own work keeps every gate.
|
|
266
|
+
if (flags.thirdParty) {
|
|
267
|
+
process.exitCode = await thirdPartyReviewCommand({ flags: { ...flags, task: task ?? null }, config });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
262
270
|
// KJC-TSK-0913: the verdicts kj already holds, published on the PR as one review.
|
|
263
271
|
if (flags.publishPr) {
|
|
264
272
|
const r = await publishPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.publishPr });
|
package/src/commands/harden.js
CHANGED
|
@@ -16,12 +16,14 @@ import { interactiveHarden } from "../harden/interactive.js";
|
|
|
16
16
|
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
17
17
|
import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
|
|
18
18
|
import { installConfigsForRoots } from "../harden/config-engine.js";
|
|
19
|
+
import { readHardenConfig } from "../harden/harden-scope.js";
|
|
19
20
|
import { installGuidelines } from "../harden/guidelines-engine.js";
|
|
20
21
|
import { maybeRulesyncGenerate } from "../utils/rulesync.js";
|
|
21
22
|
import { commandsForLanguage } from "../harden/hook-commands.js";
|
|
22
23
|
import { installHooks } from "../harden/harden-engine.js";
|
|
23
24
|
import { installHarnessHooks } from "../harden/harness-hooks.js";
|
|
24
25
|
import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
26
|
+
import { installSandbox } from "../harden/sandbox.js";
|
|
25
27
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
26
28
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
27
29
|
import { adviseBranchProtection } from "../harden/branch-protection.js";
|
|
@@ -35,21 +37,6 @@ const JS_TEST_CMD = {
|
|
|
35
37
|
mocha: "npx mocha",
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
* Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
|
|
40
|
-
* sub-tools (e.g. a python wrapper) excludes them once instead of on every
|
|
41
|
-
* invocation (KJC-TSK-0564). CLI flags are merged on top.
|
|
42
|
-
*/
|
|
43
|
-
function readHardenConfig(projectDir) {
|
|
44
|
-
const pkgPath = join(projectDir, "package.json");
|
|
45
|
-
if (!existsSync(pkgPath)) return {};
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
|
|
48
|
-
} catch {
|
|
49
|
-
return {};
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
/**
|
|
54
41
|
* Resolve the hook's lint/format/test commands for the primary language.
|
|
55
42
|
* Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
|
|
@@ -105,6 +92,7 @@ export async function hardenCommand({
|
|
|
105
92
|
only = [],
|
|
106
93
|
exclude = [],
|
|
107
94
|
commitSupervisor = false,
|
|
95
|
+
sandbox = true,
|
|
108
96
|
kjVersion = null,
|
|
109
97
|
// KJC-BUG-0289: the contract files already dirty before anything was generated
|
|
110
98
|
// (kj init captures them before writing its own). What is dirty NOW is the person's.
|
|
@@ -174,6 +162,8 @@ export async function hardenCommand({
|
|
|
174
162
|
// sin sello, que es como una sesión se dejaba sin herramientas.
|
|
175
163
|
const sh = installSentinelHooks({ projectDir, logger, human: commitSupervisor });
|
|
176
164
|
result.sentinelHooks = sh.wired ? "wired" : "script-only";
|
|
165
|
+
// KJC-TSK-1092: the host sandbox, on by default wherever it can start here.
|
|
166
|
+
if (sandbox) result.sandbox = installSandbox({ projectDir, logger }).state;
|
|
177
167
|
}
|
|
178
168
|
} catch (err) {
|
|
179
169
|
if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
|
package/src/commands/rag.js
CHANGED
|
@@ -38,7 +38,7 @@ async function indexFiles({ files, projectDir, db, embedder, slug, logger, json,
|
|
|
38
38
|
logger.warn?.(`[rag] ${out}: not indexed`);
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
|
-
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(
|
|
41
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
42
42
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
43
43
|
}
|
|
44
44
|
const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
|
|
@@ -26,6 +26,8 @@ import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
|
26
26
|
import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
|
|
27
27
|
import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
|
|
28
28
|
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
29
|
+
import { prSizeLimit } from "../review/pr-requirements.js";
|
|
30
|
+
import { stewardBlocking } from "../steward/gate.js";
|
|
29
31
|
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
30
32
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
31
33
|
import { evaluatePolicyGate } from "../review/policy-gate.js";
|
|
@@ -227,6 +229,14 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
227
229
|
const card = await enforceCardFirst({ config, projectDir });
|
|
228
230
|
if (!card.ok) return { verdict: "rejected", reviewer: "card-first", issues: [{ severity: "high", description: card.reason }] };
|
|
229
231
|
|
|
232
|
+
// KJC-TSK-1086: the Steward's block at commit too, not only where Edit starts work.
|
|
233
|
+
const steward = stewardBlocking({ projectDir, config });
|
|
234
|
+
if (steward) {
|
|
235
|
+
console.log(`✗ ${steward.reason}`);
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return { verdict: "rejected", reviewer: "steward", issues: [{ severity: "high", description: steward.reason }] };
|
|
238
|
+
}
|
|
239
|
+
|
|
230
240
|
// KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
|
|
231
241
|
// single test change warn (block via method_gates.tests_with_code).
|
|
232
242
|
const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
@@ -246,16 +256,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
246
256
|
// PL-C (KJC-TSK-0735), fuente única de umbrales: si la policy declara el
|
|
247
257
|
// invariante net_lines_added, SU max es el umbral — el mismo número que
|
|
248
258
|
// aplican kj policy check y el tier C de CI; method_gates queda de fallback.
|
|
249
|
-
const pol = loadPolicy({ projectDir });
|
|
250
|
-
const locInv = pol.errors.length === 0
|
|
251
|
-
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
252
|
-
: null;
|
|
253
259
|
// KJC-BUG-0326 (#2085): two tiers. ~150 is the ideal (method_gates.pr_size_warn);
|
|
254
260
|
// up to the limit (the policy's net_lines_added max, else method_gates.pr_size_max,
|
|
255
|
-
// 200) is allowed without asking; above the limit, partition.
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const sizeSource =
|
|
261
|
+
// 200) is allowed without asking; above the limit, partition. KJC-TSK-1076: one
|
|
262
|
+
// source, shared with the third-party PR checks.
|
|
263
|
+
const pol = loadPolicy({ projectDir });
|
|
264
|
+
const { ideal: sizeIdeal, limit: sizeWarn, source: sizeSource } = prSizeLimit({ projectDir, config });
|
|
259
265
|
if (sizeWarn > 0) {
|
|
260
266
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
261
267
|
// KJC-BUG-0205: the budget caps CODE growth, so generated output and human
|
package/src/commands/worktree.js
CHANGED
|
@@ -34,7 +34,8 @@ export async function worktreeCommand({ config = null, action, args = [], flags
|
|
|
34
34
|
throw new Error(`lane "${slug}" already exists — cd ${wtPath}, or finish it with: kj worktree done ${slug}`);
|
|
35
35
|
}
|
|
36
36
|
const branch = `feat/${slug}`;
|
|
37
|
-
|
|
37
|
+
// KJC-TSK-1093: --no-track, so no upstream lands in .git/config (read-only in the host sandbox).
|
|
38
|
+
await git(["worktree", "add", "--no-track", "-b", branch, wtPath]);
|
|
38
39
|
const boot = await bootstrapWorktree({ worktreePath: wtPath, setupCommand: config?.session?.worktree_setup || null });
|
|
39
40
|
console.log(`✓ lane ready: ${branch}`);
|
|
40
41
|
console.log(` cd ${wtPath}`);
|
package/src/git/hu-automation.js
CHANGED
|
@@ -150,9 +150,9 @@ export async function prepareHuBranch({ story, huBranches, config, logger, cardI
|
|
|
150
150
|
*/
|
|
151
151
|
export function pushRemedy(message, branch) {
|
|
152
152
|
if (/workflow/i.test(message) && /scope/i.test(message)) {
|
|
153
|
-
return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push
|
|
153
|
+
return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push origin ${branch}`;
|
|
154
154
|
}
|
|
155
|
-
return `push the branch yourself: git push
|
|
155
|
+
return `push the branch yourself: git push origin ${branch}`;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/** The warnings a run reports when the push or the PR of any HU failed. */
|
package/src/harden/check.js
CHANGED
|
@@ -11,8 +11,9 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { runCommand } from "../utils/process.js";
|
|
14
|
-
import {
|
|
14
|
+
import { configLeftAlone } from "./config-engine.js";
|
|
15
15
|
import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
|
|
16
|
+
import { readHardenConfig } from "./harden-scope.js";
|
|
16
17
|
import { PROFILE_HOOKS } from "./hook-templates.js";
|
|
17
18
|
import { detectStackRoots } from "./stack-roots.js";
|
|
18
19
|
import { qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
@@ -49,24 +50,24 @@ export async function checkHarden({ projectDir = process.cwd(), profile = "stand
|
|
|
49
50
|
// Config + workflows ship with standard+ — and surface the greenfield gap:
|
|
50
51
|
// a language now present but never hardened ⇒ its config shows as missing.
|
|
51
52
|
if (profile !== "minimal") {
|
|
52
|
-
|
|
53
|
+
// KJC-BUG-0370: the same scope (package.json kj.harden) and the same verdict on a
|
|
54
|
+
// missing config as kj harden, so check never calls drift what harden leaves alone.
|
|
55
|
+
const { only = [], exclude = [] } = readHardenConfig(projectDir);
|
|
56
|
+
const roots = detectStackRoots(projectDir, { only, exclude });
|
|
57
|
+
const configCheck = (cfg, rel, altDirs, missing) => {
|
|
58
|
+
const leftAlone = existsSync(join(projectDir, rel)) ? null : configLeftAlone(cfg, altDirs);
|
|
59
|
+
if (!leftAlone) return presence(projectDir, rel, `config:${rel}`, missing);
|
|
60
|
+
const detail = leftAlone.action === "covered" ? `covered by ${leftAlone.by}` : `omitted: ${leftAlone.note}`;
|
|
61
|
+
return { id: `config:${rel}`, ok: true, detail };
|
|
62
|
+
};
|
|
53
63
|
for (const cfg of UNIVERSAL_CONFIGS) {
|
|
54
|
-
checks.push(
|
|
64
|
+
checks.push(configCheck(cfg, cfg.file, [projectDir], "missing — run kj harden"));
|
|
55
65
|
}
|
|
56
66
|
for (const { dir, language } of roots) {
|
|
57
67
|
const rootDir = dir === "." ? projectDir : join(projectDir, dir);
|
|
58
68
|
for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
|
|
59
69
|
const rel = dir === "." ? cfg.file : join(dir, cfg.file);
|
|
60
|
-
|
|
61
|
-
// is not drift — kj must not demand a second linter/formatter (KJC-TSK-0614).
|
|
62
|
-
if (!existsSync(join(projectDir, rel))) {
|
|
63
|
-
const alt = findAlternative([rootDir, projectDir], artifactIdForConfig(cfg));
|
|
64
|
-
if (alt) {
|
|
65
|
-
checks.push({ id: `config:${rel}`, ok: true, detail: `covered by ${alt.foundAt} (${alt.tool})` });
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
|
|
70
|
+
checks.push(configCheck(cfg, rel, [rootDir, projectDir], `missing for ${language} — run kj harden`));
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
const expectedWf = [...WORKFLOWS];
|
|
@@ -33,6 +33,30 @@ function hasTool(dirs, tool) {
|
|
|
33
33
|
return false;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Why kj leaves a missing config alone, or null when it seeds it. KJC-BUG-0370:
|
|
38
|
+
* kj check reads the same answer, so it never calls drift what harden skips.
|
|
39
|
+
*/
|
|
40
|
+
export function configLeftAlone(cfg, altDirs) {
|
|
41
|
+
const id = artifactIdForConfig(cfg);
|
|
42
|
+
const alt = findAlternative(altDirs, id);
|
|
43
|
+
if (alt) return { action: "covered", by: alt.foundAt };
|
|
44
|
+
// KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
|
|
45
|
+
// package.json#eslintConfig…) means the config already EXISTS — seeding
|
|
46
|
+
// kj's default filename next to it would silently ECLIPSE the user's
|
|
47
|
+
// real config (eslint resolves eslint.config.js before .mjs).
|
|
48
|
+
const variant = findEquivalentIn(altDirs, id);
|
|
49
|
+
if (variant) return { action: "covered", by: variant };
|
|
50
|
+
// KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
|
|
51
|
+
return missingToolGap(cfg, altDirs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** KJC-BUG-0376: the first tool the config needs and the project lacks, as a report entry. */
|
|
55
|
+
function missingToolGap(cfg, dirs) {
|
|
56
|
+
const missing = [cfg.requires ?? []].flat().find((tool) => !hasTool(dirs, tool));
|
|
57
|
+
return missing ? { action: "omitted", note: `${missing} is not installed — npm i -D ${missing}, then re-run kj harden` } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
36
60
|
function writeFile(target, content) {
|
|
37
61
|
mkdirSync(dirname(target), { recursive: true });
|
|
38
62
|
writeFileSync(target, content);
|
|
@@ -50,30 +74,10 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
|
|
|
50
74
|
const exists = existsSync(target);
|
|
51
75
|
const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
// KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
|
|
60
|
-
// package.json#eslintConfig…) means the config already EXISTS — seeding
|
|
61
|
-
// kj's default filename next to it would silently ECLIPSE the user's
|
|
62
|
-
// real config (eslint resolves eslint.config.js before .mjs).
|
|
63
|
-
const variant = findEquivalentIn(altDirs, artifactIdForConfig(cfg));
|
|
64
|
-
if (variant) {
|
|
65
|
-
results.push({ file, action: "covered", by: variant });
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
// KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
|
|
69
|
-
if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
|
|
70
|
-
results.push({
|
|
71
|
-
file,
|
|
72
|
-
action: "omitted",
|
|
73
|
-
note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden`,
|
|
74
|
-
});
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
+
const leftAlone = exists ? null : configLeftAlone(cfg, altDirs);
|
|
78
|
+
if (leftAlone) {
|
|
79
|
+
results.push({ file, ...leftAlone });
|
|
80
|
+
continue;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
if (cfg.json) {
|
|
@@ -88,6 +92,12 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
|
|
|
88
92
|
results.push({ file, action: "skipped" });
|
|
89
93
|
continue;
|
|
90
94
|
}
|
|
95
|
+
// KJC-BUG-0376: never rewrite a managed block into one the project cannot load.
|
|
96
|
+
const gap = exists ? missingToolGap(cfg, altDirs) : null;
|
|
97
|
+
if (gap) {
|
|
98
|
+
results.push({ file, ...gap });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
91
101
|
const { content, action } = upsertManagedBlock({
|
|
92
102
|
source,
|
|
93
103
|
blockId: cfg.blockId,
|
|
@@ -72,6 +72,19 @@ export const ESLINT_BODY = [
|
|
|
72
72
|
"];",
|
|
73
73
|
].join("\n");
|
|
74
74
|
|
|
75
|
+
// KJC-BUG-0376: a TypeScript root gets the same rules with the TypeScript parser;
|
|
76
|
+
// without it ESLint 9 fails every .ts/.tsx file with "Parsing error".
|
|
77
|
+
const JS_LANGUAGE_OPTIONS = 'languageOptions: { ecmaVersion: 2025, sourceType: "module" }';
|
|
78
|
+
if (!ESLINT_BODY.includes(JS_LANGUAGE_OPTIONS)) throw new Error("config-templates: ESLINT_BODY lost its languageOptions line");
|
|
79
|
+
export const ESLINT_TS_BODY = [
|
|
80
|
+
'import tsParser from "@typescript-eslint/parser";',
|
|
81
|
+
"",
|
|
82
|
+
ESLINT_BODY.replace(
|
|
83
|
+
JS_LANGUAGE_OPTIONS,
|
|
84
|
+
'languageOptions: { parser: tsParser, ecmaVersion: 2025, sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } }',
|
|
85
|
+
),
|
|
86
|
+
].join("\n");
|
|
87
|
+
|
|
75
88
|
// Ruff covers lint + format for Python; `UP` (pyupgrade) is the analogue of
|
|
76
89
|
// the ES2025 deprecated-API ban — it rewrites legacy idioms to modern Python.
|
|
77
90
|
export const RUFF_BODY = [
|
|
@@ -114,6 +127,9 @@ export const JS_CONFIGS = [
|
|
|
114
127
|
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
|
|
115
128
|
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
|
|
116
129
|
];
|
|
130
|
+
export const TS_CONFIGS = JS_CONFIGS.map((cfg) => (cfg.file === "eslint.config.js"
|
|
131
|
+
? { ...cfg, body: ESLINT_TS_BODY, requires: ["eslint", "@typescript-eslint/parser"] }
|
|
132
|
+
: cfg));
|
|
117
133
|
export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
|
|
118
134
|
export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
|
|
119
135
|
|
|
@@ -135,7 +151,7 @@ export const GENERATED_CONFIG_FILES = new Set(
|
|
|
135
151
|
/** Lint/format configs by detected language. Unknown ⇒ universal only. */
|
|
136
152
|
export const CONFIGS_BY_LANGUAGE = {
|
|
137
153
|
javascript: JS_CONFIGS,
|
|
138
|
-
typescript:
|
|
154
|
+
typescript: TS_CONFIGS,
|
|
139
155
|
python: PY_CONFIGS,
|
|
140
156
|
go: GO_CONFIGS,
|
|
141
157
|
php: PHP_CONFIGS,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
|
|
3
|
+
* sub-tools (e.g. a python wrapper) excludes them once instead of on every
|
|
4
|
+
* invocation (KJC-TSK-0564). KJC-BUG-0370: kj harden and kj check read it from
|
|
5
|
+
* here, so check never asks for configs harden was told to leave out.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export function readHardenConfig(projectDir) {
|
|
11
|
+
const pkgPath = join(projectDir, "package.json");
|
|
12
|
+
if (!existsSync(pkgPath)) return {};
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
const
|
|
13
|
+
// KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
|
|
14
|
+
export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
|
|
15
|
+
export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
|
|
15
16
|
|
|
16
17
|
export const SHEBANG = "#!/usr/bin/env sh";
|
|
17
18
|
|
|
@@ -45,10 +46,12 @@ export function toPortableHooksDir(dir, home = homedir()) {
|
|
|
45
46
|
function chainToGlobal(hook, globalHooksDir) {
|
|
46
47
|
if (!globalHooksDir) return [];
|
|
47
48
|
const dir = toPortableHooksDir(globalHooksDir);
|
|
49
|
+
// KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
|
|
50
|
+
const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "${dir}/${hook}" "$@"; rc=$?` : `"${dir}/${hook}" "$@"; rc=$?`;
|
|
48
51
|
return [
|
|
49
52
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
50
53
|
`if [ -x "${dir}/${hook}" ]; then`,
|
|
51
|
-
`
|
|
54
|
+
` ${call}`,
|
|
52
55
|
' [ "$rc" -eq 0 ] || exit "$rc"',
|
|
53
56
|
"fi",
|
|
54
57
|
];
|
|
@@ -100,6 +103,22 @@ function identityGuard(hook) {
|
|
|
100
103
|
}
|
|
101
104
|
return [
|
|
102
105
|
...head,
|
|
106
|
+
// KJC-TSK-1085: every commit the push brings carries the declared identity
|
|
107
|
+
// (a rebase, cherry-pick, merge or tag never ran pre-commit).
|
|
108
|
+
" kj_declared_email=$(sed -n 's/^git_email:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
|
|
109
|
+
' if [ -n "$kj_declared_email" ]; then',
|
|
110
|
+
' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
|
|
111
|
+
' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
|
|
112
|
+
// A new ref: what the PUSH TARGET ($1, the remote git hands the hook) does not have yet.
|
|
113
|
+
' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_rsha..$kj_lsha") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
|
|
114
|
+
" for kj_c in $kj_new; do",
|
|
115
|
+
' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
|
|
116
|
+
' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
|
|
117
|
+
' echo "kj harden: identity lock — commit $(git log -1 --format=%h "$kj_c") is authored/committed as $kj_who but this clone is declared as $kj_declared_email"; exit 1',
|
|
118
|
+
" fi",
|
|
119
|
+
" done",
|
|
120
|
+
" done || exit 1",
|
|
121
|
+
" fi",
|
|
103
122
|
" kj_declared_gh=$(sed -n 's/^gh_user:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
|
|
104
123
|
' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
|
|
105
124
|
" 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)",
|
|
@@ -178,6 +197,8 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
178
197
|
].join("\n");
|
|
179
198
|
case "pre-push": {
|
|
180
199
|
const lines = [
|
|
200
|
+
// KJC-TSK-1085: git hands the pushed refs on stdin; kept for the identity check and the chained hook.
|
|
201
|
+
"[ -t 0 ] || kj_push_refs=$(cat)",
|
|
181
202
|
"# Identity guard — never push without a configured git identity.",
|
|
182
203
|
'if [ -z "$(git config user.email)" ] || [ -z "$(git config user.name)" ]; then',
|
|
183
204
|
" echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
|
package/src/harden/human-act.js
CHANGED
|
@@ -8,18 +8,36 @@
|
|
|
8
8
|
* 4. a random nonce typed back, read from the real controlling terminal.
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
12
13
|
|
|
13
14
|
const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
const procRead = (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
|
+
|
|
24
|
+
// KJC-TSK-1084: without /proc (macOS) the chain comes from ps, so a human
|
|
25
|
+
// there is not refused for an ancestry nobody could read.
|
|
26
|
+
// The system binary by absolute path: a ps resolved through PATH could be the
|
|
27
|
+
// agent's own forgery prepended to it.
|
|
28
|
+
const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? null;
|
|
29
|
+
const psOutput = (p) => {
|
|
30
|
+
if (!PS_BIN) throw new Error("no system ps (/bin/ps, /usr/bin/ps)");
|
|
31
|
+
return execFileSync(PS_BIN, ["-o", "ppid=", "-o", "command=", "-p", String(p)], { encoding: "utf8", env: {} });
|
|
32
|
+
};
|
|
33
|
+
const psRead = (ps) => (p) => {
|
|
34
|
+
const m = /^\s*(\d+)\s+(.*)$/s.exec(String(ps(p) ?? ""));
|
|
35
|
+
if (!m) throw new Error(`ps gave nothing for pid ${p}`);
|
|
36
|
+
return { ppid: Number(m[1]), cmd: m[2].trim() };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40, hasProc = existsSync("/proc/self/stat"), ps = psOutput } = {}) {
|
|
40
|
+
const read = readProc || (hasProc ? procRead : psRead(ps));
|
|
23
41
|
let cur = pid;
|
|
24
42
|
for (let i = 0; i < maxDepth && cur > 1; i += 1) {
|
|
25
43
|
let info;
|
|
@@ -37,6 +55,11 @@ export function refuseAgentSession(what, { env = process.env, tty = process.stdo
|
|
|
37
55
|
throw new Error(`${what} es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)`);
|
|
38
56
|
}
|
|
39
57
|
const anc = agentAncestry(ancestry);
|
|
58
|
+
// KJC-TSK-1084: fail closed. Nothing else stops a human act any more (the
|
|
59
|
+
// Sentinel does not read its text), so an ancestry that cannot be read is no pass.
|
|
60
|
+
if (anc.unknown) {
|
|
61
|
+
throw new Error(`${what} es un acto humano y no se pudo comprobar la ascendencia de procesos — córrelo desde TU terminal (ADR 0009)`);
|
|
62
|
+
}
|
|
40
63
|
if (anc.agent) {
|
|
41
64
|
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
65
|
}
|