karajan-code 4.9.0 → 4.10.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/esbuild-sea.config.mjs +6 -1
- package/scripts/verify-pack.mjs +17 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +12 -0
- package/src/commands/env.js +47 -1
- package/src/commands/harden.js +7 -0
- package/src/commands/privacy.js +45 -0
- package/src/commands/review-gate.js +57 -3
- package/src/environment/playbook.js +2 -0
- package/src/harden/harness-hooks.js +80 -0
- package/src/privacy/onboarding.js +35 -0
- package/src/privacy/scan.js +120 -0
package/package.json
CHANGED
|
@@ -146,7 +146,8 @@ const huBoardStubPlugin = {
|
|
|
146
146
|
const ragStubPlugin = {
|
|
147
147
|
name: "rag-stub",
|
|
148
148
|
setup(build) {
|
|
149
|
-
|
|
149
|
+
// KJC-TSK-0704 — src/privacy/* rides this stub too: its engine is redactPII.
|
|
150
|
+
build.onResolve({ filter: /[\\/](rag[\\/].+|privacy[\\/].+|commands[\\/](rag|watch|privacy))\.js$/ }, (args) => ({
|
|
150
151
|
path: args.path, namespace: "rag-stub",
|
|
151
152
|
}));
|
|
152
153
|
build.onLoad({ filter: /.*/, namespace: "rag-stub" }, () => ({
|
|
@@ -192,6 +193,10 @@ const ragStubPlugin = {
|
|
|
192
193
|
// reached from \`kj rag install-hooks\`, so it degrades like the rest.
|
|
193
194
|
maybeAutoUpdate: async () => ({ skipped: true }),
|
|
194
195
|
installPostMergeHook: notAvailable,
|
|
196
|
+
// KJC-TSK-0704 — privacy scan (engine = karajan-rag redactPII).
|
|
197
|
+
privacyScanCommand: notAvailable, loadPrivacyList: notAvailable,
|
|
198
|
+
scanText: notAvailable, scanPaths: notAvailable, privacyConfigPath: notAvailable,
|
|
199
|
+
ensurePrivacyList: async () => ({ created: false, reason: "sea-stub" }),
|
|
195
200
|
// KJC-BUG-0100 — the doctor rag-hooks check imports this; it throws
|
|
196
201
|
// here and the check swallows it (degrades to a benign info result).
|
|
197
202
|
resolveHooksDir: notAvailable,
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -85,6 +85,23 @@ try {
|
|
|
85
85
|
}
|
|
86
86
|
console.log(`verify-pack: kj --version → ${versionOut} ✓`);
|
|
87
87
|
|
|
88
|
+
// 1.5 Privacy boundary (KJC-TSK-0706): nothing personal or secret-shaped
|
|
89
|
+
// ships inside the tarball. Scans the INSTALLED package with the same
|
|
90
|
+
// engine as the pre-commit gate: denylist + token shapes abort the
|
|
91
|
+
// publish; generic PII only counts (code trees always carry a few
|
|
92
|
+
// pattern lookalikes).
|
|
93
|
+
const { scanPaths, loadPrivacyList } = await import(
|
|
94
|
+
path.join(repoRoot, "src", "privacy", "scan.js")
|
|
95
|
+
);
|
|
96
|
+
const shippedDir = path.join(tmpDir, "node_modules", "karajan-code");
|
|
97
|
+
const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
|
|
98
|
+
const pBlocks = pFindings.filter((f) => f.severity === "block");
|
|
99
|
+
if (pBlocks.length > 0) {
|
|
100
|
+
for (const f of pBlocks) console.error(`✗ [${f.type}] ${path.relative(shippedDir, f.source)}:${f.line} → ${f.masked}`);
|
|
101
|
+
fail(`${pBlocks.length} personal-data/secret hit(s) INSIDE the tarball — this must not publish`);
|
|
102
|
+
}
|
|
103
|
+
console.log(`verify-pack: tarball privacy scan ✓ (${pFindings.length} generic warning(s))`);
|
|
104
|
+
|
|
88
105
|
// 2. kj --help must exit 0 (exercises the command tree wiring).
|
|
89
106
|
try {
|
|
90
107
|
run(binPath, ["--help"]);
|
|
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy"] },
|
|
34
34
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -134,6 +134,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
134
134
|
.description("Install/refresh the Karajan playbook for any host agent (CLAUDE.md, AGENTS.md, GEMINI.md)")
|
|
135
135
|
.option("--target <target>", "claude | codex | gemini | all", "all")
|
|
136
136
|
.option("--no-rag", "Skip building the RAG index when the project has none (ENV-E1)")
|
|
137
|
+
.option("--no-enforce", "Skip installing the git hooks + verdict gate (KJC-BUG-0133 — installing IS activating; skipping this leaves the method narratable)")
|
|
137
138
|
.action(async (flags) => {
|
|
138
139
|
await withConfig(pkgVersion, "env-install", flags, async ({ config, logger }) => {
|
|
139
140
|
const r = await envInstallCommand({ config, logger, flags });
|
|
@@ -261,6 +262,17 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
261
262
|
});
|
|
262
263
|
});
|
|
263
264
|
|
|
265
|
+
// KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
|
|
266
|
+
const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
|
|
267
|
+
privacy.command("scan [paths...]")
|
|
268
|
+
.description("Scan files/dirs for personal data, always masked in output: denylist hits (~/.karajan/privacy.yml) block with exit 1, generic PII (email/phone/DNI/NIE/IBAN/card via karajan-rag redactPII) warns")
|
|
269
|
+
.option("--staged", "Scan the ADDED lines of the staged diff instead of paths")
|
|
270
|
+
.option("--json", "Machine-readable result (stdout carries exactly one JSON document)")
|
|
271
|
+
.action(async (paths, flags) => {
|
|
272
|
+
const { privacyScanCommand } = await import("../commands/privacy.js");
|
|
273
|
+
await privacyScanCommand({ paths, flags });
|
|
274
|
+
});
|
|
275
|
+
|
|
264
276
|
const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
|
|
265
277
|
rag.command("index")
|
|
266
278
|
.description("Index plans + onboarding (and optionally project sources) into the local vector store")
|
package/src/commands/env.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* files (CLAUDE.md, AGENTS.md), and make its step 1 real: when the project
|
|
5
5
|
* has no RAG index yet, build it (default ON, `--no-rag` opts out).
|
|
6
6
|
*/
|
|
7
|
-
import { installPlaybook } from "../environment/playbook.js";
|
|
7
|
+
import { installPlaybook, renderPlaybook } from "../environment/playbook.js";
|
|
8
8
|
import { renderBrief, listBriefs } from "../environment/briefs.js";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/vec-store.js";
|
|
@@ -12,6 +12,11 @@ import { ragIndexCommand } from "./rag.js";
|
|
|
12
12
|
import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
|
|
13
13
|
import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
|
|
14
14
|
import { verifyBoardAccess } from "../environment/board-access.js";
|
|
15
|
+
import { ensurePrivacyList } from "../privacy/onboarding.js";
|
|
16
|
+
import { createWizard } from "../utils/wizard.js";
|
|
17
|
+
import { hardenCommand } from "./harden.js";
|
|
18
|
+
import { reviewGateCommand } from "./review-gate.js";
|
|
19
|
+
import { join } from "node:path";
|
|
15
20
|
|
|
16
21
|
function hasRagIndex(config, projectDir) {
|
|
17
22
|
// KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
|
|
@@ -66,6 +71,35 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
66
71
|
}
|
|
67
72
|
if (access.backend !== "hu-board") console.log(`✓ board access verified: ${access.via}`);
|
|
68
73
|
|
|
74
|
+
// KJC-BUG-0133 — installing IS activating. The setup flow used to order
|
|
75
|
+
// harden + the verdict gate as TEXT steps the agent runs (or narrates);
|
|
76
|
+
// a session that skipped them got a decorative method — field case
|
|
77
|
+
// 2026-08-02: installed karajan, then wrote everything by hand with
|
|
78
|
+
// nothing to stop it. env install now does them itself, idempotently.
|
|
79
|
+
if (flags.enforce !== false) {
|
|
80
|
+
if (!existsSync(join(projectDir, ".git"))) {
|
|
81
|
+
result.exitCode = PENDING_EXIT_CODE;
|
|
82
|
+
console.log(renderPendingBlock(
|
|
83
|
+
[{ tool: "git", action: "needs-user", reason: "the enforcement gates live in git hooks — run `git init` (Karajan's guarantees do not exist without git)" }],
|
|
84
|
+
{ retry: "kj env install" },
|
|
85
|
+
));
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const h = await hardenCommand({ projectDir, logger: console });
|
|
90
|
+
if (h?.ok === false) throw new Error(h.error || "kj harden failed");
|
|
91
|
+
await reviewGateCommand({ config, flags: { installGate: true } });
|
|
92
|
+
console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
|
|
93
|
+
} catch (err) {
|
|
94
|
+
result.exitCode = PENDING_EXIT_CODE;
|
|
95
|
+
console.log(renderPendingBlock(
|
|
96
|
+
[{ tool: "enforcement", action: "needs-user", reason: `hooks/gate could not be installed: ${err.message} — without them the method is narratable` }],
|
|
97
|
+
{ retry: "kj env install" },
|
|
98
|
+
));
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
// ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
|
|
70
104
|
// so installing the environment guarantees the index exists. KJC-TSK-0659
|
|
71
105
|
// stop-on-sudo: an index that cannot be built is a BLOCKING condition —
|
|
@@ -128,7 +162,19 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
128
162
|
}
|
|
129
163
|
}
|
|
130
164
|
if (result.exitCode !== PENDING_EXIT_CODE) {
|
|
165
|
+
// KJC-TSK-0707 (PV-D) — privacy onboarding rides the install: kj asks,
|
|
166
|
+
// kj writes the denylist. Best-effort (stubbed in the SEA binary).
|
|
167
|
+
try {
|
|
168
|
+
const wizard = process.stdin.isTTY && process.stdout.isTTY ? createWizard() : null;
|
|
169
|
+
await ensurePrivacyList({ wizard, logger: console, isTTY: Boolean(wizard) });
|
|
170
|
+
wizard?.close();
|
|
171
|
+
} catch { /* privacy onboarding is best-effort */ }
|
|
131
172
|
console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
|
|
173
|
+
// KJC-BUG-0133 (part 2): a mid-session install lands the playbook in
|
|
174
|
+
// CLAUDE.md, but the running session loaded its context BEFORE — nobody
|
|
175
|
+
// re-reads it. Print the method so it enters THIS conversation now.
|
|
176
|
+
console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
|
|
177
|
+
console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
|
|
132
178
|
}
|
|
133
179
|
return result;
|
|
134
180
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -18,6 +18,7 @@ import { installConfigsForRoots } from "../harden/config-engine.js";
|
|
|
18
18
|
import { installGuidelines } from "../harden/guidelines-engine.js";
|
|
19
19
|
import { commandsForLanguage } from "../harden/hook-commands.js";
|
|
20
20
|
import { installHooks } from "../harden/harden-engine.js";
|
|
21
|
+
import { installHarnessHooks } from "../harden/harness-hooks.js";
|
|
21
22
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
22
23
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
23
24
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
@@ -140,6 +141,12 @@ export async function hardenCommand({
|
|
|
140
141
|
let result;
|
|
141
142
|
try {
|
|
142
143
|
result = await installHooks({ projectDir, profile, cmds, dryRun, baseBranch });
|
|
144
|
+
// KJC-TSK-0710 — the TOOL gate: rules imposed at tool time (Claude Code
|
|
145
|
+
// PreToolUse). Standard+ only; minimal stays hooks-lite.
|
|
146
|
+
if (profile !== "minimal" && !dryRun) {
|
|
147
|
+
const hh = installHarnessHooks({ projectDir, logger });
|
|
148
|
+
result.harnessHooks = hh.wired ? "wired" : "script-only";
|
|
149
|
+
}
|
|
143
150
|
} catch (err) {
|
|
144
151
|
if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
|
|
145
152
|
else logger.error?.(`kj harden: ${err.message}`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj privacy scan` (KJC-TSK-0704) — audit an outbound boundary before it
|
|
3
|
+
* ships: files/dirs or the staged diff's ADDED lines. Denylist blocks
|
|
4
|
+
* (exit 1), generic PII warns; findings always come out masked.
|
|
5
|
+
*/
|
|
6
|
+
import { runCommand } from "../utils/process.js";
|
|
7
|
+
import { loadPrivacyList, scanPaths, scanText, privacyConfigPath } from "../privacy/scan.js";
|
|
8
|
+
|
|
9
|
+
const addedLines = (diff) =>
|
|
10
|
+
diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
|
|
11
|
+
|
|
12
|
+
export async function privacyScanCommand({ paths = [], flags = {}, logger = console } = {}) {
|
|
13
|
+
const list = loadPrivacyList();
|
|
14
|
+
let findings;
|
|
15
|
+
if (flags.staged) {
|
|
16
|
+
const res = await runCommand("git", ["diff", "--cached", "--no-ext-diff", "--unified=0"]);
|
|
17
|
+
findings = scanText(addedLines(res.stdout || ""), { list, source: "<staged diff>" });
|
|
18
|
+
} else {
|
|
19
|
+
if (paths.length === 0) {
|
|
20
|
+
logger.error?.("kj privacy scan: pass one or more paths, or --staged");
|
|
21
|
+
process.exitCode = 2;
|
|
22
|
+
return { ok: false, findings: [] };
|
|
23
|
+
}
|
|
24
|
+
findings = scanPaths(paths, { list });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const blocks = findings.filter((f) => f.severity === "block");
|
|
28
|
+
const warns = findings.filter((f) => f.severity === "warn");
|
|
29
|
+
const ok = blocks.length === 0;
|
|
30
|
+
process.exitCode = ok ? 0 : 1;
|
|
31
|
+
// --json keeps stdout machine-clean: exactly one JSON document, no prose.
|
|
32
|
+
if (flags.json) {
|
|
33
|
+
process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
|
|
34
|
+
return { ok, findings };
|
|
35
|
+
}
|
|
36
|
+
for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
37
|
+
for (const f of warns) logger.warn?.(`⚠ warn [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
38
|
+
if (!list.present) {
|
|
39
|
+
logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
|
|
40
|
+
}
|
|
41
|
+
logger.info?.(ok
|
|
42
|
+
? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
|
|
43
|
+
: `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
|
|
44
|
+
return { ok, findings };
|
|
45
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* verdict tied to the exact diff (verdict-store), so the pre-commit hook
|
|
6
6
|
* (ENV-C) can verify it. Exit code 0 = approved, 1 = rejected/stale.
|
|
7
7
|
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
8
10
|
import { runCommand } from "../utils/process.js";
|
|
9
11
|
import { checkVerdict } from "../review/verdict-store.js";
|
|
10
12
|
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
@@ -13,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
|
13
15
|
import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
|
|
14
16
|
import { checkCardFirst } from "../review/card-first.js";
|
|
15
17
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
18
|
+
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
16
19
|
|
|
17
20
|
// KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
|
|
18
21
|
// (before spending sonar/reviewer effort) AND on --check — the pre-commit
|
|
@@ -86,9 +89,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
86
89
|
|
|
87
90
|
if (flags.installGate) {
|
|
88
91
|
const fs = await import("node:fs/promises");
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
await fs.mkdir(path.dirname(marker), { recursive: true });
|
|
92
|
+
const marker = join(projectDir, ".karajan", "review-gate");
|
|
93
|
+
await fs.mkdir(join(projectDir, ".karajan"), { recursive: true });
|
|
92
94
|
await fs.writeFile(marker, "# Cross-AI review gate enabled (ENV-C1). Commit this file so the whole team inherits the gate.\n");
|
|
93
95
|
console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
|
|
94
96
|
// KJC-TSK-0646: a `.karajan/` dir-exclude would silently keep the
|
|
@@ -135,6 +137,44 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
}
|
|
140
|
+
// KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
|
|
141
|
+
// Denylist data rejects (KJ_ALLOW_PII=1 is the named escape); generic PII
|
|
142
|
+
// warns (privacy.generic: "block" hardens). Added lines only — deletions
|
|
143
|
+
// don't publish. In the SEA binary the privacy module is stubbed and
|
|
144
|
+
// throws: the gate degrades with a note instead of crashing.
|
|
145
|
+
try {
|
|
146
|
+
const added = diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
|
|
147
|
+
const findings = scanText(added, { list: loadPrivacyList(), source: "<staged diff>" });
|
|
148
|
+
const blocks = findings.filter((f) => f.severity === "block");
|
|
149
|
+
const warns = findings.filter((f) => f.severity === "warn");
|
|
150
|
+
for (const f of warns) console.log(`⚠ privacy: [${f.type}] added line ${f.line} → ${f.masked} — personal data? move it out before it ships`);
|
|
151
|
+
const hardened = config?.privacy?.generic === "block" && warns.length > 0;
|
|
152
|
+
if (blocks.length > 0 || hardened) {
|
|
153
|
+
if (process.env.KJ_ALLOW_PII === "1") {
|
|
154
|
+
console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
155
|
+
} else {
|
|
156
|
+
for (const f of blocks) console.log(`✗ privacy: [${f.type}] on added line ${f.line} → ${f.masked}`);
|
|
157
|
+
const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
|
|
158
|
+
console.log(`✗ privacy gate: ${reason}`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Known degradation: the SEA stub throws its install-from-npm message.
|
|
165
|
+
// Anything else fails CLOSED — a silent skip would defeat the guarantee.
|
|
166
|
+
if (/standalone binary/i.test(err?.message || "")) {
|
|
167
|
+
console.log("⚠ privacy gate unavailable in this build — skipping");
|
|
168
|
+
} else if (process.env.KJ_ALLOW_PII === "1") {
|
|
169
|
+
console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
170
|
+
} else {
|
|
171
|
+
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked (KJ_ALLOW_PII=1 to override consciously)`;
|
|
172
|
+
console.log(`✗ ${reason}`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
const tests = checkTestsWithCode({ config, stagedFiles: changedFiles });
|
|
139
179
|
if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
|
|
140
180
|
if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
|
|
@@ -145,6 +185,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
145
185
|
}
|
|
146
186
|
|
|
147
187
|
if (flags.check) {
|
|
188
|
+
// KJC-BUG-0132 (issue #1344): a PURE merge commit stages no content of
|
|
189
|
+
// its own — everything it brings reached the parent branches already
|
|
190
|
+
// reviewed, and there is no diff to bind a verdict to, so demanding one
|
|
191
|
+
// deadlocks the merge. A merge WITH conflict resolutions stages real
|
|
192
|
+
// content and still requires its verdict below.
|
|
193
|
+
if (!flags.range && !diff.trim()) {
|
|
194
|
+
const gitDirRaw = (await runCommand("git", ["rev-parse", "--git-dir"])).stdout?.trim();
|
|
195
|
+
const gitDir = gitDirRaw && !isAbsolute(gitDirRaw) ? join(projectDir, gitDirRaw) : gitDirRaw;
|
|
196
|
+
if (gitDir && existsSync(join(gitDir, "MERGE_HEAD"))) {
|
|
197
|
+
console.log("✓ pure merge commit — empty staged diff, parents already reviewed; gate passes with trail");
|
|
198
|
+
process.exitCode = 0;
|
|
199
|
+
return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
148
202
|
const res = await checkVerdict(projectDir, diff);
|
|
149
203
|
console.log(res.ok
|
|
150
204
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
@@ -75,6 +75,8 @@ Invariants (the git gates enforce these — they are not suggestions):
|
|
|
75
75
|
- A non-trivial plan names at least two approaches — one as if the
|
|
76
76
|
codebase didn't exist — and says why the winner won. Following the
|
|
77
77
|
legacy line is a choice, never a default.
|
|
78
|
+
- Publishing ANY artifact (a build's dist/, a docs site, a tarball)?
|
|
79
|
+
\`kj privacy scan <dir>\` first — nothing personal or secret-shaped ships.
|
|
78
80
|
- Branch first: never commit on the base branch — every change reaches it
|
|
79
81
|
through an atomic PR (~150 net lines, Conventional Commits). More than one
|
|
80
82
|
task, or the base tree must stay untouched? \`kj worktree start <slug>\`
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* harness-hooks — the TOOL gate (KJC-TSK-0710, from proposal KJC-PRP-0013).
|
|
3
|
+
* Field case 2026-08-02: text rules were not enough — the environment must
|
|
4
|
+
* impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
|
|
5
|
+
* it into the project's `.claude/settings.json` (merged, never clobbered):
|
|
6
|
+
* Write over an existing file blocks ("use Edit", KJ_ALLOW_WRITE=1 escapes);
|
|
7
|
+
* Bash that reserializes whole JSON files blocks (KJ_ALLOW_REWRITE=1).
|
|
8
|
+
* Claude-only v1 — the abstraction arrives with the second host that
|
|
9
|
+
* supports tool hooks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const SCRIPT_REL = join(".karajan", "harness", "pretooluse.mjs");
|
|
16
|
+
|
|
17
|
+
const SCRIPT_BODY = `#!/usr/bin/env node
|
|
18
|
+
// kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
|
|
19
|
+
// tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
|
|
20
|
+
// so a gate bug never bricks the session.
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
let raw = "";
|
|
23
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
24
|
+
process.stdin.on("end", () => {
|
|
25
|
+
try {
|
|
26
|
+
const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
27
|
+
if (tool === "Write" && process.env.KJ_ALLOW_WRITE !== "1") {
|
|
28
|
+
if (input.file_path && existsSync(input.file_path)) {
|
|
29
|
+
console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes (KJ_ALLOW_WRITE=1 to override consciously).");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (tool === "Bash" && process.env.KJ_ALLOW_REWRITE !== "1") {
|
|
34
|
+
const cmd = String(input.command || "");
|
|
35
|
+
const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
|
|
36
|
+
if (/json\\.dumps?\\s*\\(/.test(cmd) && writes) {
|
|
37
|
+
console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch { /* fail open */ }
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const hookEntry = (matcher) => ({ matcher, hooks: [{ type: "command", command: `node ${SCRIPT_REL}` }] });
|
|
47
|
+
|
|
48
|
+
/** Write the script and merge the PreToolUse entries into .claude/settings.json. */
|
|
49
|
+
export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
|
|
50
|
+
const scriptAbs = join(projectDir, SCRIPT_REL);
|
|
51
|
+
mkdirSync(join(projectDir, ".karajan", "harness"), { recursive: true });
|
|
52
|
+
writeFileSync(scriptAbs, SCRIPT_BODY, { mode: 0o755 });
|
|
53
|
+
|
|
54
|
+
const settingsPath = join(projectDir, ".claude", "settings.json");
|
|
55
|
+
let settings = {};
|
|
56
|
+
if (existsSync(settingsPath)) {
|
|
57
|
+
try {
|
|
58
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (tool gate script written, wire it manually)`);
|
|
61
|
+
return { script: scriptAbs, wired: false };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
settings.hooks = settings.hooks || {};
|
|
65
|
+
// Preserve, never clobber: an existing PreToolUse with an unexpected shape
|
|
66
|
+
// is the user's business — leave the file alone (same as invalid JSON).
|
|
67
|
+
if ("PreToolUse" in settings.hooks && !Array.isArray(settings.hooks.PreToolUse)) {
|
|
68
|
+
logger.warn?.(`kj harden: ${settingsPath} has a non-array hooks.PreToolUse — leaving it untouched (tool gate script written, wire it manually)`);
|
|
69
|
+
return { script: scriptAbs, wired: false };
|
|
70
|
+
}
|
|
71
|
+
const pre = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
72
|
+
for (const matcher of ["Write", "Bash"]) {
|
|
73
|
+
const present = pre.some((e) => e?.matcher === matcher && JSON.stringify(e).includes("pretooluse.mjs"));
|
|
74
|
+
if (!present) pre.push(hookEntry(matcher));
|
|
75
|
+
}
|
|
76
|
+
settings.hooks.PreToolUse = pre;
|
|
77
|
+
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
78
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
79
|
+
return { script: scriptAbs, wired: true };
|
|
80
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* privacy onboarding (KJC-TSK-0707, PV-D) — the user never fills YAML by
|
|
3
|
+
* hand: `kj env install` asks and kj writes `~/.karajan/privacy.yml`.
|
|
4
|
+
* Every question skips on Enter; answers are NEVER echoed back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
10
|
+
import { privacyConfigPath } from "./scan.js";
|
|
11
|
+
|
|
12
|
+
export async function ensurePrivacyList({ wizard = null, logger = console, isTTY = process.stdout.isTTY } = {}) {
|
|
13
|
+
const target = privacyConfigPath();
|
|
14
|
+
if (existsSync(target)) return { created: false, reason: "present" };
|
|
15
|
+
if (!isTTY || !wizard) {
|
|
16
|
+
logger.info?.(`⚠ privacy: no personal denylist yet — re-run interactively or create ${target} (personal:/allow:) so YOUR data blocks at every outbound boundary`);
|
|
17
|
+
return { created: false, reason: "non-interactive" };
|
|
18
|
+
}
|
|
19
|
+
logger.info?.("privacy: let's protect your personal data at every outbound boundary (Enter skips any question).");
|
|
20
|
+
const csv = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
21
|
+
const personal = [
|
|
22
|
+
...csv(await wizard.input("Personal emails that must NEVER be published (comma-separated):")),
|
|
23
|
+
...csv(await wizard.input("Personal phone / ID numbers to block (comma-separated, optional):")),
|
|
24
|
+
...csv(await wizard.input("Full name to block (optional):")),
|
|
25
|
+
];
|
|
26
|
+
const allow = csv(await wizard.input("Public identities that are FINE to publish (handles, comma-separated):"));
|
|
27
|
+
if (personal.length === 0 && allow.length === 0) {
|
|
28
|
+
logger.info?.("privacy: skipped — generic PII detection still warns; create the denylist later to make YOUR data block");
|
|
29
|
+
return { created: false, reason: "skipped" };
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
32
|
+
writeFileSync(target, `# kj privacy denylist — GLOBAL, never commit this file to any repo.\n${stringifyYaml({ personal, allow })}`);
|
|
33
|
+
logger.info?.(`✓ privacy: denylist written to ${target} (${personal.length} entrada(s), values not echoed)`);
|
|
34
|
+
return { created: true, entries: personal.length };
|
|
35
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* privacy — the outbound boundary scanner (KJC-TSK-0704, epic KJC-PCS-0070).
|
|
3
|
+
* Born from a real incident (personal emails published on a landing):
|
|
4
|
+
* outputs get sanitized at boundaries like inputs do. Engine: karajan-rag's
|
|
5
|
+
* audited redactPII line by line — detects AND masks in one pass, so a
|
|
6
|
+
* finding never echoes the datum. On top, the user's denylist from
|
|
7
|
+
* `~/.karajan/privacy.yml` — GLOBAL, never in a repo: the list is sensitive.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { extname, join } from "node:path";
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
14
|
+
import { redactPII } from "karajan-rag";
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".kj"]);
|
|
17
|
+
const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".db", ".sqlite"]);
|
|
18
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
19
|
+
|
|
20
|
+
export function privacyConfigPath(home = os.homedir()) {
|
|
21
|
+
// KJ_PRIVACY_CONFIG: test/CI override of the global denylist location.
|
|
22
|
+
return process.env.KJ_PRIVACY_CONFIG || join(home, ".karajan", "privacy.yml");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Personal denylist + public-identity allowlist. Absent file → generics only. */
|
|
26
|
+
export function loadPrivacyList({ home = os.homedir() } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = parseYaml(readFileSync(privacyConfigPath(home), "utf8")) || {};
|
|
29
|
+
const clean = (xs) => (Array.isArray(xs) ? xs.map(String).map((s) => s.trim()).filter(Boolean) : []);
|
|
30
|
+
return { personal: clean(raw.personal), allow: clean(raw.allow), present: true };
|
|
31
|
+
} catch {
|
|
32
|
+
return { personal: [], allow: [], present: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const maskValue = (v) => (v.length <= 4 ? "***" : `${v.slice(0, 2)}***${v.slice(-2)}`);
|
|
37
|
+
|
|
38
|
+
// KJC-TSK-0708 — hardcoded secrets. Known token shapes are never legit in
|
|
39
|
+
// an outbound artifact → block; heuristics (credential assignments and
|
|
40
|
+
// user:pass@ connection strings) warn toward .env / a secret manager.
|
|
41
|
+
const SECRET_SHAPES = [
|
|
42
|
+
{ type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
43
|
+
{ type: "openai-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
44
|
+
{ type: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
45
|
+
{ type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
46
|
+
{ type: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
47
|
+
{ type: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
48
|
+
{ type: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
49
|
+
];
|
|
50
|
+
const SECRET_HEURISTICS = [
|
|
51
|
+
{ type: "secret-assignment", re: /\b(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|auth)["']?\s*[:=]\s*["']([^"']{6,})["']/gi },
|
|
52
|
+
{ type: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/gi },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scan a text. Returns findings — denylist hits as `severity:"block"`
|
|
57
|
+
* (masked value), generic PII as `severity:"warn"` (line already redacted).
|
|
58
|
+
*/
|
|
59
|
+
export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
String(text).split("\n").forEach((line, i) => {
|
|
62
|
+
let probe = line;
|
|
63
|
+
for (const a of list.allow) probe = probe.split(a).join(" ");
|
|
64
|
+
for (const p of list.personal) {
|
|
65
|
+
let at = probe.toLowerCase().indexOf(p.toLowerCase());
|
|
66
|
+
if (at === -1) continue;
|
|
67
|
+
findings.push({ severity: "block", type: "denylist", source, line: i + 1, masked: maskValue(p) });
|
|
68
|
+
// Blank every occurrence so the generics don't double-report it.
|
|
69
|
+
while (at !== -1) { probe = probe.slice(0, at) + " ".repeat(p.length) + probe.slice(at + p.length); at = probe.toLowerCase().indexOf(p.toLowerCase()); }
|
|
70
|
+
}
|
|
71
|
+
for (const { type, re } of SECRET_SHAPES) {
|
|
72
|
+
re.lastIndex = 0;
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = re.exec(probe)) !== null) {
|
|
75
|
+
findings.push({ severity: "block", type, source, line: i + 1, masked: maskValue(m[0]) });
|
|
76
|
+
probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
|
|
77
|
+
re.lastIndex = m.index + m[0].length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const { type, re } of SECRET_HEURISTICS) {
|
|
81
|
+
re.lastIndex = 0;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(probe)) !== null) {
|
|
84
|
+
findings.push({ severity: "warn", type, source, line: i + 1, masked: `${maskValue(m[1])} — move it to .env or a secret manager` });
|
|
85
|
+
probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
|
|
86
|
+
re.lastIndex = m.index + m[0].length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const red = redactPII(probe);
|
|
90
|
+
if (red.total > 0) {
|
|
91
|
+
for (const [type, n] of Object.entries(red.counts)) {
|
|
92
|
+
if (n > 0) findings.push({ severity: "warn", type, source, line: i + 1, masked: red.text.trim().slice(0, 120) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Scan files/dirs recursively (skips node_modules/.git, binaries, big files). */
|
|
100
|
+
export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
const visit = (p) => {
|
|
103
|
+
if (!existsSync(p)) return;
|
|
104
|
+
// Skip symlinks: an ancestor link recurses forever; an outside link ships nothing.
|
|
105
|
+
const st = lstatSync(p);
|
|
106
|
+
if (st.isSymbolicLink()) return;
|
|
107
|
+
if (st.isDirectory()) {
|
|
108
|
+
for (const name of readdirSync(p)) {
|
|
109
|
+
if (!SKIP_DIRS.has(name)) visit(join(p, name));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (BINARY_EXT.has(extname(p).toLowerCase()) || st.size > MAX_FILE_BYTES) return;
|
|
114
|
+
const content = readFileSync(p);
|
|
115
|
+
if (content.includes(0)) return; // binary sniff: NUL byte
|
|
116
|
+
findings.push(...scanText(content.toString("utf8"), { list, source: p }));
|
|
117
|
+
};
|
|
118
|
+
for (const p of paths) visit(p);
|
|
119
|
+
return findings;
|
|
120
|
+
}
|