karajan-code 4.55.0 → 4.56.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/cli/register-pipeline.js +8 -0
- package/src/commands/harden.js +1 -15
- package/src/commands/rag.js +1 -1
- package/src/commands/review-gate.js +14 -8
- package/src/harden/check.js +14 -13
- package/src/harden/config-engine.js +25 -24
- 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/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/third-party-review.js +67 -0
- package/src/steward/gate.js +31 -0
package/package.json
CHANGED
|
@@ -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,6 +16,7 @@ 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";
|
|
@@ -35,21 +36,6 @@ const JS_TEST_CMD = {
|
|
|
35
36
|
mocha: "npx mocha",
|
|
36
37
|
};
|
|
37
38
|
|
|
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
39
|
/**
|
|
54
40
|
* Resolve the hook's lint/format/test commands for the primary language.
|
|
55
41
|
* Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
|
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/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,27 @@ 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
|
+
if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
|
|
52
|
+
return { action: "omitted", note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden` };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
function writeFile(target, content) {
|
|
37
58
|
mkdirSync(dirname(target), { recursive: true });
|
|
38
59
|
writeFileSync(target, content);
|
|
@@ -50,30 +71,10 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
|
|
|
50
71
|
const exists = existsSync(target);
|
|
51
72
|
const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
|
|
52
73
|
|
|
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
|
-
}
|
|
74
|
+
const leftAlone = exists ? null : configLeftAlone(cfg, altDirs);
|
|
75
|
+
if (leftAlone) {
|
|
76
|
+
results.push({ file, ...leftAlone });
|
|
77
|
+
continue;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
if (cfg.json) {
|
|
@@ -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
|
}
|
|
@@ -475,7 +475,7 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
|
|
|
475
475
|
import { spawnSync } from "node:child_process";
|
|
476
476
|
import { homedir } from "node:os";
|
|
477
477
|
import { fileURLToPath } from "node:url";
|
|
478
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session,
|
|
478
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
479
479
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
480
480
|
import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
|
|
481
481
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
@@ -485,7 +485,6 @@ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[
|
|
|
485
485
|
// KJC-BUG-0333: a publish is read per simple command by the shell reader, so
|
|
486
486
|
// words inside a quoted argument of another command are not a publish.
|
|
487
487
|
const isPublish = isPublishCommand;
|
|
488
|
-
const PUSH = /\\bgit\\s+push\\b/;
|
|
489
488
|
const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
|
|
490
489
|
let raw = "";
|
|
491
490
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -606,32 +605,17 @@ process.stdin.on("end", () => {
|
|
|
606
605
|
}
|
|
607
606
|
}
|
|
608
607
|
}
|
|
609
|
-
// KJC-TSK-0822 v1.1:
|
|
610
|
-
//
|
|
611
|
-
//
|
|
608
|
+
// KJC-TSK-0822 v1.1: la clave del movil (capa 5) no se toca desde la sesion.
|
|
609
|
+
// KJC-TSK-1084 (ADR 0015): los comandos de acto humano (kj harden --commit,
|
|
610
|
+
// kj identity enroll-phone) ya no se leen aqui: la propia orden rechaza un
|
|
611
|
+
// proceso que desciende de un agente (src/harden/human-act.js). Queda el
|
|
612
|
+
// FICHERO de la clave, que solo esto protege hasta que el sandbox lo vete.
|
|
612
613
|
{
|
|
613
|
-
// KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es
|
|
614
|
-
const
|
|
615
|
-
const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
|
|
614
|
+
// KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es escribirlo.
|
|
615
|
+
const namesPhoneFile = stripTextOnlyCommandValues(String(input.command || "")).includes("supervisor-phone.json");
|
|
616
616
|
const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
|
|
617
|
-
if ((tool === "Bash" &&
|
|
618
|
-
console.error("karajan sentinel:
|
|
619
|
-
process.exit(2);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
623
|
-
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
624
|
-
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
625
|
-
// (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
|
|
626
|
-
// KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
|
|
627
|
-
// git commit, que solo guardan texto (decision del usuario, 16-oct).
|
|
628
|
-
// ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
|
|
629
|
-
// arriba: un env var lo pone tambien un agente — el supervisor no se
|
|
630
|
-
// apaga con interruptores al alcance del supervisado.
|
|
631
|
-
{
|
|
632
|
-
const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
|
|
633
|
-
if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
|
|
634
|
-
console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
|
|
617
|
+
if ((tool === "Bash" && namesPhoneFile) || writesEnroll) {
|
|
618
|
+
console.error("karajan sentinel: la clave del movil (capa 5) es del humano — supervisor-phone.json no se toca desde la sesion; enrolar es kj identity enroll-phone desde su terminal." + doc("supervisor"));
|
|
635
619
|
process.exit(2);
|
|
636
620
|
}
|
|
637
621
|
}
|
|
@@ -676,7 +660,6 @@ process.stdin.on("end", () => {
|
|
|
676
660
|
return v.length > 0 ? v : null;
|
|
677
661
|
};
|
|
678
662
|
const declaredGh = idLine("gh_user");
|
|
679
|
-
const declaredEmail = idLine("git_email");
|
|
680
663
|
const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
|
|
681
664
|
const activeGh = () => {
|
|
682
665
|
if (!existsSync(ghHostsPath)) return null;
|
|
@@ -699,11 +682,11 @@ process.stdin.on("end", () => {
|
|
|
699
682
|
// $( and backticks are separators too (review catch): their payload is its own segment.
|
|
700
683
|
const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
|
|
701
684
|
const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
|
|
702
|
-
const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth
|
|
703
|
-
const checkSeg = (ws, depth
|
|
685
|
+
const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
|
|
686
|
+
const checkSeg = (ws, depth) => {
|
|
704
687
|
if (ws.length === 0) return;
|
|
705
|
-
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/
|
|
706
|
-
const j = ws.findIndex((w, i) => i > 0 && ["gh",
|
|
688
|
+
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
|
|
689
|
+
const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
|
|
707
690
|
if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
|
|
708
691
|
return;
|
|
709
692
|
}
|
|
@@ -715,46 +698,9 @@ process.stdin.on("end", () => {
|
|
|
715
698
|
if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
|
|
716
699
|
const eff = switchedTo || activeGh();
|
|
717
700
|
if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
if (ws[0] === "git") {
|
|
721
|
-
// Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
|
|
722
|
-
// are forwarded verbatim to git var, so git resolves identity exactly as the real
|
|
723
|
-
// command would (review catches: -C cumulative, --git-dir, --opt=value forms).
|
|
724
|
-
const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
|
|
725
|
-
let k = 1;
|
|
726
|
-
while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
|
|
727
|
-
const globals = ws.slice(1, k).map(bare);
|
|
728
|
-
const sub = ws[k];
|
|
729
|
-
if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
|
|
730
|
-
if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
|
|
731
|
-
const eff = switchedTo || activeGh();
|
|
732
|
-
if (eff !== declaredGh) problems.push("git push with gh session '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix: gh auth switch --user " + declaredGh + " && ...");
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
// IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
|
|
736
|
-
if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
|
|
737
|
-
if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
|
|
738
|
-
// Let GIT resolve author and committer under the command's own environment
|
|
739
|
-
// (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
|
|
740
|
-
// its business): git var is the single source of truth.
|
|
741
|
-
const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
|
|
742
|
-
const env = Object.assign({}, process.env);
|
|
743
|
-
for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
|
|
744
|
-
const ident = (v) => {
|
|
745
|
-
const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
|
|
746
|
-
const s = r.status === 0 ? String(r.stdout) : "";
|
|
747
|
-
const a = s.indexOf("<");
|
|
748
|
-
const b = s.indexOf(">");
|
|
749
|
-
return a >= 0 && b > a ? s.slice(a + 1, b) : null;
|
|
750
|
-
};
|
|
751
|
-
const ai = ws.findIndex((w) => w.startsWith("--author"));
|
|
752
|
-
const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
|
|
753
|
-
const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
|
|
754
|
-
const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
|
|
755
|
-
const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
|
|
756
|
-
if (offender !== null) problems.push("git " + sub + " as '" + offender + "' but this clone is declared as '" + declaredEmail + "' — use: git -c user.email=" + declaredEmail + " " + sub + " ... (or git config user.email " + declaredEmail + ")");
|
|
757
701
|
}
|
|
702
|
+
// KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
|
|
703
|
+
// pre-commit checks the author, pre-push every pushed commit and the gh session.
|
|
758
704
|
};
|
|
759
705
|
scan(CMD_TEXT, 0);
|
|
760
706
|
if (problems.length > 0) {
|
|
@@ -1177,17 +1123,14 @@ process.stdin.on("end", () => {
|
|
|
1177
1123
|
}
|
|
1178
1124
|
}
|
|
1179
1125
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1180
|
-
|
|
1126
|
+
// KJC-TSK-1089: git push is not read any more; pending moves still stop the turn (stop gate).
|
|
1127
|
+
const ADVANCE = /\\bgit\\s+commit\\b|\\bgh\\s+pr\\s+create\\b/;
|
|
1181
1128
|
const mergeM = cmd.match(MERGE);
|
|
1182
1129
|
const pend = pendingMoves(load().sessions?.[sid]);
|
|
1183
|
-
// KJC-BUG-0364: one board-gate decision per command; the push gate below
|
|
1184
|
-
// reuses it, so a second slow gh cannot contradict the first answer.
|
|
1185
|
-
let boardDecision = null;
|
|
1186
1130
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
1187
1131
|
// KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
|
|
1188
1132
|
// por su primera mitad (boardGate, en la lib).
|
|
1189
|
-
|
|
1190
|
-
const { blocking, carried, reason } = boardDecision;
|
|
1133
|
+
const { blocking, carried, reason } = boardGate(sid, pend);
|
|
1191
1134
|
for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
|
|
1192
1135
|
// ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
|
|
1193
1136
|
// are already decided above (KJC-BUG-0230, 0198).
|
|
@@ -1234,18 +1177,10 @@ process.stdin.on("end", () => {
|
|
|
1234
1177
|
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
|
|
1235
1178
|
process.exit(2);
|
|
1236
1179
|
}
|
|
1237
|
-
} else if (PUSH.test(cmd)) {
|
|
1238
|
-
// KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
|
|
1239
|
-
// ADR 0015 (KJC-TSK-0925): with no escape.
|
|
1240
|
-
// KJC-BUG-0262: a carried card does not block the push the commit let through.
|
|
1241
|
-
const sess = load().sessions?.[sid];
|
|
1242
|
-
const decision = boardDecision || boardGate(sid, pendingMoves(sess));
|
|
1243
|
-
const v = violations(sess, branchOf(), decision.blocking);
|
|
1244
|
-
if (v.length) {
|
|
1245
|
-
console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + (decision.reason ? "\\n(" + decision.reason + ")" : "") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
|
|
1246
|
-
process.exit(2);
|
|
1247
|
-
}
|
|
1248
1180
|
}
|
|
1181
|
+
// KJC-TSK-1089 (ADR 0015): git push is no longer read here. The base branch is
|
|
1182
|
+
// protected (required checks), the card and the tests are judged at commit
|
|
1183
|
+
// (kj review), and pending card moves keep the turn from ending (stop gate).
|
|
1249
1184
|
}
|
|
1250
1185
|
} catch { /* fail open */ }
|
|
1251
1186
|
process.exit(0);
|
package/src/rag/indexer.js
CHANGED
|
@@ -173,7 +173,7 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
173
173
|
if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
|
|
174
174
|
}
|
|
175
175
|
for (const s of sources) {
|
|
176
|
-
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(
|
|
176
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
177
177
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
@@ -211,7 +211,7 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
|
|
|
211
211
|
logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
|
|
212
212
|
} else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
|
|
213
213
|
// KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
|
|
214
|
-
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(
|
|
214
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
215
215
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
216
216
|
}
|
|
217
217
|
}
|
|
@@ -55,12 +55,17 @@ export async function runOneShotReview({
|
|
|
55
55
|
createAgentFn = createAgent,
|
|
56
56
|
detectAgents = detectAvailableAgents,
|
|
57
57
|
candidateStatusFn = candidateStatus,
|
|
58
|
+
// KJC-TSK-1075: a third-party PR is not the host's own work, so the reviewer
|
|
59
|
+
// it names runs even if it is the host; and nothing is stored, so the
|
|
60
|
+
// review can never unlock a commit of one's own.
|
|
61
|
+
reviewerOverride = null,
|
|
62
|
+
persist = true,
|
|
58
63
|
}) {
|
|
59
64
|
if (!diff || !diff.trim()) {
|
|
60
65
|
throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
const reviewer = await pickCrossReviewer({ config, hostAgent, detectAgents });
|
|
68
|
+
const reviewer = reviewerOverride || await pickCrossReviewer({ config, hostAgent, detectAgents });
|
|
64
69
|
if (!reviewer) {
|
|
65
70
|
throw new Error(
|
|
66
71
|
`cross-AI review requires an agent other than the host (${hostAgent || "unknown"}) — install codex, claude or another supported CLI`
|
|
@@ -123,7 +128,7 @@ export async function runOneShotReview({
|
|
|
123
128
|
throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
const record = {
|
|
127
132
|
verdict: parsed.approved ? "approved" : "rejected",
|
|
128
133
|
reviewer: activeReviewer,
|
|
129
134
|
host: hostAgent || null,
|
|
@@ -140,5 +145,6 @@ export async function runOneShotReview({
|
|
|
140
145
|
// the verdict, bound to this diff, like the other two proofs.
|
|
141
146
|
...(ui ? { ui } : {}),
|
|
142
147
|
confidence: parsed.confidence ?? null,
|
|
143
|
-
}
|
|
148
|
+
};
|
|
149
|
+
return persist ? saveVerdict(projectDir, diff, record) : record;
|
|
144
150
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1076: the requirements of a third-party PR, checked with no tokens.
|
|
3
|
+
* Each check is {id, ok, detail}; none stops the review (the agent decides).
|
|
4
|
+
* The rules are the own-work ones: the same budget, Conventional Commits and
|
|
5
|
+
* attribution patterns, and tests-with-code.
|
|
6
|
+
*/
|
|
7
|
+
import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
|
|
8
|
+
import { loadPolicy } from "../policy/engine.js";
|
|
9
|
+
import { runCommand } from "../utils/process.js";
|
|
10
|
+
import { budgetedAdded } from "./loc-budget.js";
|
|
11
|
+
import { checkTestsWithCode } from "./tests-with-code.js";
|
|
12
|
+
|
|
13
|
+
const HEADER_MAX = 100;
|
|
14
|
+
const CONVENTIONAL = new RegExp(String.raw`^(${CONVENTIONAL_TYPES})(\([^)]+\))?!?: \S`);
|
|
15
|
+
const ATTRIBUTION = new RegExp(AI_ATTRIBUTION, "i");
|
|
16
|
+
|
|
17
|
+
const rowsOf = (numstat) => numstat.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
18
|
+
const [a, r, ...f] = l.split(/\s+/);
|
|
19
|
+
return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The PR size budget, one source for every gate (PL-C, KJC-TSK-0735): the
|
|
24
|
+
* policy's net_lines_added invariant, else method_gates.pr_size_max, 200.
|
|
25
|
+
*/
|
|
26
|
+
export function prSizeLimit({ projectDir, config }) {
|
|
27
|
+
const pol = loadPolicy({ projectDir });
|
|
28
|
+
const inv = pol.errors.length === 0
|
|
29
|
+
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
30
|
+
: null;
|
|
31
|
+
return {
|
|
32
|
+
ideal: config?.method_gates?.pr_size_warn ?? 150,
|
|
33
|
+
limit: inv?.max ?? config?.method_gates?.pr_size_max ?? 200,
|
|
34
|
+
source: inv ? `policy [${inv.id}]` : "method_gates",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Lines added that count against the budget (generated output and human docs exempt). */
|
|
39
|
+
export function sizeCheck({ numstat, limit }) {
|
|
40
|
+
const { added, exempt } = budgetedAdded(numstat);
|
|
41
|
+
const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
|
|
42
|
+
return added > limit
|
|
43
|
+
? { id: "size", ok: false, detail: `${added} lines added${exemptNote}, over the ${limit}-line limit: partition the PR` }
|
|
44
|
+
: { id: "size", ok: true, detail: `${added} lines added${exemptNote}, within the ${limit}-line limit` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
|
|
48
|
+
export function commitChecks(commits) {
|
|
49
|
+
const badHeader = commits.filter((c) => !CONVENTIONAL.test(c.subject) || c.subject.length > HEADER_MAX);
|
|
50
|
+
const attributed = commits.filter((c) => ATTRIBUTION.test(`${c.subject}\n${c.body}`));
|
|
51
|
+
const list = (cs) => cs.map((c) => `${c.sha.slice(0, 8)} "${c.subject}"`).join(", ");
|
|
52
|
+
return [
|
|
53
|
+
badHeader.length
|
|
54
|
+
? { id: "conventional-commits", ok: false, detail: `not Conventional Commits or over ${HEADER_MAX} characters: ${list(badHeader)}` }
|
|
55
|
+
: { id: "conventional-commits", ok: true, detail: `${commits.length} commit(s) with Conventional Commits headers` },
|
|
56
|
+
attributed.length
|
|
57
|
+
? { id: "ai-attribution", ok: false, detail: `AI attribution in: ${list(attributed)}` }
|
|
58
|
+
: { id: "ai-attribution", ok: true, detail: "no AI attribution in the commits" },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Source changed with no test changed (a delete-only change needs none). */
|
|
63
|
+
export function testsCheck({ config, numstat }) {
|
|
64
|
+
const rows = rowsOf(numstat);
|
|
65
|
+
const res = checkTestsWithCode({ config, stagedFiles: rows.map((r) => r.file), numstat: rows });
|
|
66
|
+
const ok = res.ok && res.mode !== "warn";
|
|
67
|
+
return { id: "tests", ok, detail: ok ? `tests: ${res.mode}` : res.reason || "source changed with no test changed" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const git = async (projectDir, args) => {
|
|
71
|
+
const res = await runCommand("git", args, { cwd: projectDir });
|
|
72
|
+
if (res.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${(res.stderr || "").trim()}`);
|
|
73
|
+
return res.stdout;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Size, commits and tests of base...head, read from git. */
|
|
77
|
+
export async function prRequirementChecks({ range, projectDir, config, run = git }) {
|
|
78
|
+
const numstat = await run(projectDir, ["diff", "--numstat", range]);
|
|
79
|
+
// The commits of a PR are base..head, whichever range notation was given.
|
|
80
|
+
const [base, head] = range.split(/\.{2,3}/);
|
|
81
|
+
const log = await run(projectDir, ["log", "--format=%H%x1f%s%x1f%b%x1e", `${base}..${head || "HEAD"}`]);
|
|
82
|
+
const commits = log.split("\x1e").map((r) => r.trim()).filter(Boolean).map((r) => {
|
|
83
|
+
const [sha, subject = "", body = ""] = r.split("\x1f");
|
|
84
|
+
return { sha, subject, body };
|
|
85
|
+
});
|
|
86
|
+
return [sizeCheck({ numstat, limit: prSizeLimit({ projectDir, config }).limit }), ...commitChecks(commits), testsCheck({ config, numstat })];
|
|
87
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1077: the security and privacy requirements of a third-party PR,
|
|
3
|
+
* with no tokens. Each check is {id, ok, detail} and never stops the review.
|
|
4
|
+
* They reuse the scanners kj applies to its own work: the output guard's
|
|
5
|
+
* credential patterns, the privacy scan, the prompt-injection catalog, the
|
|
6
|
+
* supply-chain cooldown and semgrep, limited to the PR's files.
|
|
7
|
+
*/
|
|
8
|
+
import { CREDENTIAL_PATTERNS, scanDiff as scanGuards } from "../guards/output-guard.js";
|
|
9
|
+
import { scanDiff as scanInjection } from "../utils/injection-guard.js";
|
|
10
|
+
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
11
|
+
import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
|
|
12
|
+
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
13
|
+
import { cooldownAdvisories } from "./dep-cooldown.js";
|
|
14
|
+
|
|
15
|
+
const CREDENTIAL_IDS = new Set(CREDENTIAL_PATTERNS.map((p) => p.id));
|
|
16
|
+
const BLOCKING_SEMGREP = new Set(["ERROR", "WARNING"]);
|
|
17
|
+
const check = (id, problems, okDetail) => (problems.length
|
|
18
|
+
? { id, ok: false, detail: problems.join("; ") }
|
|
19
|
+
: { id, ok: true, detail: okDetail });
|
|
20
|
+
|
|
21
|
+
/** Credentials in added lines (AWS, private keys, tokens, URLs with passwords...). */
|
|
22
|
+
export function secretsCheck(diff, config) {
|
|
23
|
+
const found = scanGuards(diff, config).violations.filter((v) => CREDENTIAL_IDS.has(v.id));
|
|
24
|
+
return check("secrets", found.map((v) => `${v.file}:${v.line} ${v.id}`), "no secrets in the added lines");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Personal data in added lines, masked; generated output only for denylist hits. */
|
|
28
|
+
export function privacyCheck(diff, { list = loadPrivacyList() } = {}) {
|
|
29
|
+
const found = splitAddedByFile(diff).flatMap(({ file, added }) => {
|
|
30
|
+
const hits = scanText(added, { list, source: file });
|
|
31
|
+
return isGeneratedPath(file) ? hits.filter((f) => f.severity === "block") : hits;
|
|
32
|
+
});
|
|
33
|
+
return check("privacy", found.map((f) => `${f.source}:${f.line} ${f.type} ${f.masked}`), "no personal data in the added lines");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Prompt-injection patterns in the added lines (text an AI will read). */
|
|
37
|
+
export function injectionCheck(diff) {
|
|
38
|
+
const res = scanInjection(diff);
|
|
39
|
+
return check("prompt-injection", res.clean ? [] : [res.summary], "no prompt-injection patterns");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Dependencies the lockfile adds that were published under the threshold. */
|
|
43
|
+
export async function cooldownCheck(diff, { advisories = cooldownAdvisories, thresholdHours } = {}) {
|
|
44
|
+
const { young = [], unverified = [], skipped = 0 } = await advisories(diff, thresholdHours ? { thresholdHours } : {});
|
|
45
|
+
const problems = young.map((p) => `${p.name}@${p.version} published ${p.hours} h ago`);
|
|
46
|
+
const notes = [...unverified.map((p) => `${p.name}@${p.version} unverified`), ...(skipped ? [`${skipped} packages changed, none checked`] : [])];
|
|
47
|
+
const res = check("dependency-cooldown", problems, "no dependency published in the last hours");
|
|
48
|
+
return notes.length ? { ...res, detail: `${res.detail} (${notes.join("; ")})` } : res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** semgrep (the static part of kj audit --security), counted on the PR's files only. */
|
|
52
|
+
export async function semgrepCheck({ projectDir, files, collect = collectSemgrepFindings }) {
|
|
53
|
+
const res = await collect(projectDir);
|
|
54
|
+
if (!res.available) return { id: "semgrep", ok: true, detail: `not available: ${res.reason}` };
|
|
55
|
+
const inPr = new Set(files);
|
|
56
|
+
const found = (res.findings ?? []).filter((f) => inPr.has(f.file) && BLOCKING_SEMGREP.has(f.severity));
|
|
57
|
+
return check("semgrep", found.map((f) => `${f.file}:${f.line} ${f.rule}`), "no semgrep findings in the PR's files");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every security and privacy check of the PR's diff. */
|
|
61
|
+
export async function prSecurityChecks({ diff, projectDir, config }) {
|
|
62
|
+
const files = splitAddedByFile(diff).map((f) => f.file);
|
|
63
|
+
return [
|
|
64
|
+
secretsCheck(diff, config),
|
|
65
|
+
privacyCheck(diff),
|
|
66
|
+
injectionCheck(diff),
|
|
67
|
+
await cooldownCheck(diff, { thresholdHours: config?.supply_chain?.cooldown_hours }),
|
|
68
|
+
await semgrepCheck({ projectDir, files }),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1078: Sonar on a third-party PR, when SonarQube is reachable. The
|
|
3
|
+
* own-work pre-gate scans the PR's files and only issues on the lines the PR
|
|
4
|
+
* adds can fail the check; without Sonar the check says "not available" and
|
|
5
|
+
* passes (decision of the user: Sonar if available, never demanded).
|
|
6
|
+
*/
|
|
7
|
+
import { addedLinesByFile, issueFile, runSonarPregate } from "./sonar-pregate.js";
|
|
8
|
+
|
|
9
|
+
export async function sonarCheck({ diff, config, projectDir = process.cwd(), pregate = runSonarPregate }) {
|
|
10
|
+
const touchedLines = addedLinesByFile(diff);
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await pregate({ config, stagedFiles: [...touchedLines.keys()], touchedLines, projectDir });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
res = { available: false, reason: err.message };
|
|
16
|
+
}
|
|
17
|
+
if (!res.available) return { id: "sonar", ok: true, detail: `not available: ${res.reason}` };
|
|
18
|
+
const blocking = res.blocking ?? [];
|
|
19
|
+
return blocking.length
|
|
20
|
+
? { id: "sonar", ok: false, detail: blocking.map((i) => `${issueFile(i)}:${i.line} ${i.rule} ${i.severity}`).join("; ") }
|
|
21
|
+
: { id: "sonar", ok: true, detail: `no blocking Sonar issue on the added lines (${(res.advisory ?? []).length} advisory)` };
|
|
22
|
+
}
|
|
@@ -82,7 +82,8 @@ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSy
|
|
|
82
82
|
return groups;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// KJC-TSK-1078: projectDir is where the files live (a third-party PR's clone may not be the cwd).
|
|
86
|
+
export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync, projectDir = process.cwd() }) {
|
|
86
87
|
if (config?.review_gate?.sonar === false) {
|
|
87
88
|
return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
|
|
88
89
|
}
|
|
@@ -94,7 +95,9 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
94
95
|
let lock = null;
|
|
95
96
|
try {
|
|
96
97
|
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000, slots });
|
|
97
|
-
const groups = groupByScanRoot(stagedFiles, { exists });
|
|
98
|
+
const groups = groupByScanRoot(stagedFiles, { root: projectDir, exists });
|
|
99
|
+
// The root keeps the scanner's own default (cwd) unless the project lives elsewhere.
|
|
100
|
+
const rootCwd = projectDir === process.cwd() ? null : projectDir;
|
|
98
101
|
if (groups.size === 0) groups.set("", []);
|
|
99
102
|
const indexed = new Set();
|
|
100
103
|
const issues = [];
|
|
@@ -103,7 +106,8 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
103
106
|
for (const dir of groups.keys()) {
|
|
104
107
|
const prefix = dir ? `${dir}/` : "";
|
|
105
108
|
// KJC-TSK-0838: verbose, so the scanner names every file it indexed.
|
|
106
|
-
const
|
|
109
|
+
const scanCwd = dir ? join(projectDir, dir) : rootCwd;
|
|
110
|
+
const scan = await runSonarScan(config, null, { verbose: true, ...(scanCwd ? { cwd: scanCwd } : {}) });
|
|
107
111
|
if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
|
|
108
112
|
if (!scan.ok) {
|
|
109
113
|
return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1075 (#2227): review someone else's PR. kj's reviewer, the target
|
|
3
|
+
* repo's rules (.karajan) and the provider the agent picks run over
|
|
4
|
+
* base...head; no own-work gate applies (card, tests, policy, session RAG) and
|
|
5
|
+
* no verdict is stored, so this mode never unlocks a commit (ADR 0015). The
|
|
6
|
+
* own-work gate, reviewGateCommand, stays untouched.
|
|
7
|
+
*/
|
|
8
|
+
import { runCommand } from "../utils/process.js";
|
|
9
|
+
import { resolveRole } from "../config/role-resolver.js";
|
|
10
|
+
import { runOneShotReview } from "./one-shot-review.js";
|
|
11
|
+
import { prRequirementChecks } from "./pr-requirements.js";
|
|
12
|
+
import { prSecurityChecks } from "./pr-security.js";
|
|
13
|
+
import { sonarCheck } from "./pr-sonar.js";
|
|
14
|
+
|
|
15
|
+
const gitDiffOf = async (projectDir, range) => {
|
|
16
|
+
const res = await runCommand("git", ["diff", range], { cwd: projectDir });
|
|
17
|
+
if (res.exitCode !== 0) throw new Error(`git diff ${range} failed: ${(res.stderr || "").trim()}`);
|
|
18
|
+
return res.stdout;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const finding = (i) => ({
|
|
22
|
+
file: i.file ?? null, line: i.line ?? null, severity: i.severity ?? null,
|
|
23
|
+
description: i.description ?? "", suggested_fix: i.suggested_fix ?? null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The review as data: verdict, findings, suggestions and the PR-requirement checks. */
|
|
27
|
+
export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck }) {
|
|
28
|
+
const diff = await gitDiff(projectDir, range);
|
|
29
|
+
// KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
|
|
30
|
+
const checks = [...await requirements({ range, projectDir, config }), ...await security({ diff, projectDir, config }), await sonar({ diff, config, projectDir })];
|
|
31
|
+
const record = await run({
|
|
32
|
+
diff, task, config, logger, projectDir,
|
|
33
|
+
reviewerOverride: resolveRole(config, "reviewer").provider || null,
|
|
34
|
+
persist: false,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
mode: "third-party",
|
|
38
|
+
range,
|
|
39
|
+
reviewer: record.reviewer,
|
|
40
|
+
verdict: { approved: record.verdict === "approved", summary: record.summary ?? "" },
|
|
41
|
+
findings: (record.issues ?? []).map(finding),
|
|
42
|
+
suggestions: record.suggestions ?? [],
|
|
43
|
+
checks,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The CLI side: stdout carries exactly one JSON document, logs go to stderr.
|
|
49
|
+
* Exit 0 whatever the PR's verdict (the agent decides what to post); non-zero
|
|
50
|
+
* only when kj itself fails.
|
|
51
|
+
*/
|
|
52
|
+
export async function thirdPartyReviewCommand({ flags, config, out = process.stdout, err = process.stderr, review = thirdPartyReview }) {
|
|
53
|
+
const print = (doc) => out.write(`${JSON.stringify(doc)}\n`);
|
|
54
|
+
if (!flags.range) {
|
|
55
|
+
print({ error: "--third-party reviews a range: pass --range <base>...<head>" });
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const log = (msg) => err.write(`${msg}\n`);
|
|
59
|
+
const logger = { info: log, warn: log, error: log, debug: () => {} };
|
|
60
|
+
try {
|
|
61
|
+
print(await review({ range: flags.range, config, projectDir: config.projectDir || process.cwd(), logger, task: flags.task ?? null }));
|
|
62
|
+
return 0;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
print({ error: e.message });
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1086: the Steward's block (STW-C, KJC-TSK-0791) at commit too. Only
|
|
3
|
+
* where the project adopted it (method_gates.steward: block), only the hard
|
|
4
|
+
* invariants (vulnerable dependencies, a persistently red main), and never on
|
|
5
|
+
* an invariant whose verdict is unknown. Having declared block, a missing or
|
|
6
|
+
* unreadable report blocks too: the sweep has to run.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const HARD = new Set(["vulnerable-deps", "main-ci"]);
|
|
12
|
+
|
|
13
|
+
/** @returns {{ sweptAt: string, hard: object[], reason: string } | null} */
|
|
14
|
+
export function stewardBlocking({ projectDir, config }) {
|
|
15
|
+
if (config?.method_gates?.steward !== "block") return null;
|
|
16
|
+
const file = join(projectDir, ".karajan", "steward", "report.json");
|
|
17
|
+
let rep;
|
|
18
|
+
try {
|
|
19
|
+
rep = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
// block was adopted explicitly: a missing or unreadable report is never a silent pass.
|
|
22
|
+
return { sweptAt: null, hard: [], reason: `steward cannot read ${file} (${err.message}) — rerun kj steward sweep; while it is unreadable the block stands` };
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(rep?.invariants)) {
|
|
25
|
+
return { sweptAt: rep?.sweptAt ?? null, hard: [], reason: `steward report ${file} has no invariants list — rerun kj steward sweep; while it is malformed the block stands` };
|
|
26
|
+
}
|
|
27
|
+
const hard = rep.invariants.filter((r) => r?.verdict === "broken" && HARD.has(r.id));
|
|
28
|
+
if (hard.length === 0) return null;
|
|
29
|
+
const lines = hard.map((r) => `${r.id}: ${r.evidence || ""} -> ${r.remedy || r.renew || "kj steward sweep"}`);
|
|
30
|
+
return { sweptAt: rep.sweptAt, hard, reason: `steward (sweep ${rep.sweptAt}) blocks: ${lines.join("; ")} — if the work goes on, your user sets method_gates.steward to inform` };
|
|
31
|
+
}
|