karajan-code 4.11.0 → 4.13.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/README.md +11 -1
- package/docs/README.es.md +11 -1
- package/package.json +1 -1
- package/src/checks/release-check.js +109 -0
- package/src/cli/advanced-commands.js +2 -2
- package/src/cli/register-meta.js +45 -0
- package/src/commands/harden.js +5 -0
- package/src/commands/report.js +37 -2
- package/src/harden/harness-hooks.js +49 -22
- package/src/harden/sentinel-hooks.js +295 -0
package/README.md
CHANGED
|
@@ -24,12 +24,16 @@
|
|
|
24
24
|
|
|
25
25
|
Your AI agent (Claude Code, Codex, Gemini CLI, Cursor…) writes the code. **Karajan governs how it happens**: it installs a method your agent follows on every task, and enforces it with git gates that make a false green structurally impossible.
|
|
26
26
|
|
|
27
|
-
- **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses. Works out of the box: local Ollama, or the built-in ONNX embedder when nothing can be installed; cloud embedders require an explicit sensitivity declaration and PII-redact every chunk.
|
|
27
|
+
- **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses. The install wires it as a native MCP tool (`kj_rag_query`) so querying the index is the agent's cheapest path. Works out of the box: local Ollama, or the built-in ONNX embedder when nothing can be installed; cloud embedders require an explicit sensitivity declaration and PII-redact every chunk. A distilled engineering canon rides along: `kj rag query --library` serves pattern cards (when it applies, when it does NOT, the canonical citation) so plans name a greenfield alternative instead of following the legacy line by inertia.
|
|
28
28
|
- **Card first, on YOUR board** — every piece of work is tracked before it starts: kj's HU Board (`kj hu add|move|list`), the Planning Game, or the board the project already uses (Linear, Trello, Jira, GitHub Issues) via your agent's own MCP/tools. Declared, verified at install, never optional — Karajan does not run without a board. ADRs live in git (`kj adr add|list`).
|
|
29
29
|
- **Tests prove behavior** — the failing test exists first; the suite is never left red.
|
|
30
30
|
- **Deterministic first, then cross-AI review** — `kj review --staged` runs SonarQube on the changed files before any AI opinion (BLOCKER/CRITICAL reject on the spot), then binds a verdict from a *different* AI to the sha256 of the exact diff — stamped with the workspace it ran from. Without an approved verdict, **the commit does not enter** (pre-commit gate).
|
|
31
31
|
- **A third AI arbitrates disputes** — `kj solomon` rules when brain and reviewer disagree. Security findings are never overridable — not even by arbitration.
|
|
32
32
|
- **Branch first, lanes for parallel work** — the base branch only moves through atomic PRs; `kj worktree start|list|done` gives each concurrent task its own isolated lane.
|
|
33
|
+
- **Least privilege for agents** — spawned agent subprocesses receive an env allowlist (their own CLI's auth, never your cloud keys or registry tokens), and `kj check` inventories every MCP the project can reach, flagging what appeared since the last check. Sensitive-surface tasks self-invoke `kj audit --security` — a zero-token pass (prompt-injection over the agent-context files + OSV + Semgrep + Sonar) — and remediate before review.
|
|
34
|
+
- **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
|
|
35
|
+
- **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
|
|
36
|
+
- **The turn cannot end red — the Sentinel** — a deterministic supervisor (zero LLM) wired into the harness's synchronous hooks records the method state of the session as tools run, and a Stop hook blocks the agent from ending its turn while method violations are open. The program rules, the agent thinks. See [guarantee levels](#guarantee-levels-governed-vs-supervised).
|
|
33
37
|
|
|
34
38
|
This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
|
|
35
39
|
|
|
@@ -64,6 +68,12 @@ Requires git and at least one AI agent CLI — two enables cross-AI review; thre
|
|
|
64
68
|
|
|
65
69
|
Full method: [Work with your agent](https://karajancode.com/docs/v4/working-with-your-agent/) · [The gates](https://karajancode.com/docs/v4/gates/) · [Command reference](https://karajancode.com/docs/v4/commands/).
|
|
66
70
|
|
|
71
|
+
## Guarantee levels: governed vs supervised
|
|
72
|
+
|
|
73
|
+
Karajan **governs** any agent with git gates — the false green is structurally impossible no matter who writes, because the gates live in the repository, not in the agent's goodwill. On top of that, the **Karajan Sentinel** adds **supervision inside the turn**: `kj harden` wires deterministic hooks into the harness — one records the method state of the session as tools run (sources edited vs tests touched, escapes used), and a Stop hook blocks the agent from ending its turn while violations are open: sources edited on the base branch, a branch without a card, code without a single test touched. Every block states the exact violation and its remediation; `kj sentinel status` shows what the supervisor sees. It fails open — and says so — rather than ever hanging a session, and every `KJ_ALLOW_*` escape is recorded.
|
|
74
|
+
|
|
75
|
+
Synchronous blocking hooks exist today only in Claude Code. That makes the supported setup explicit: **to guarantee a harness that controls the LLM, use Claude Code as the host** — Claude writes, Codex reviews (a review subprocess needs no hooks), and a third CLI arbitrates when available. On any other host Karajan still governs at the full git-gate level and tells you which level is active — it never pretends a supervision it cannot enforce.
|
|
76
|
+
|
|
67
77
|
## Headless mode
|
|
68
78
|
|
|
69
79
|
The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
|
package/docs/README.es.md
CHANGED
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
Tu agente de IA (Claude Code, Codex, Gemini CLI, Cursor…) escribe el código. **Karajan gobierna cómo ocurre**: instala un método que tu agente sigue en cada tarea y lo hace cumplir con gates de git que hacen el falso verde estructuralmente imposible.
|
|
18
18
|
|
|
19
|
-
- **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina. Funciona de serie: Ollama local, o el embedder ONNX integrado cuando no se puede instalar nada; los embedders cloud exigen declarar la sensibilidad y redactan PII de cada chunk.
|
|
19
|
+
- **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina. La instalación lo cablea como herramienta MCP nativa (`kj_rag_query`), de modo que consultar el índice sea el camino más barato del agente. Funciona de serie: Ollama local, o el embedder ONNX integrado cuando no se puede instalar nada; los embedders cloud exigen declarar la sensibilidad y redactan PII de cada chunk. Y viaja con un canon de ingeniería destilado: `kj rag query --library` sirve fichas de patrón (cuándo aplica, cuándo NO, la cita canónica) para que los planes nombren una alternativa greenfield en vez de seguir la línea del legacy por inercia.
|
|
20
20
|
- **Card primero, en TU board** — todo trabajo se registra antes de empezar: el HU Board de kj (`kj hu add|move|list`), el Planning Game, o el board que el proyecto ya use (Linear, Trello, Jira, GitHub Issues) vía los MCP/tools de tu agente. Declarado, verificado en la instalación, jamás opcional — Karajan no funciona sin board. Los ADRs viven en git (`kj adr add|list`).
|
|
21
21
|
- **Los tests prueban el comportamiento** — el test que falla existe primero; la suite nunca se queda en rojo.
|
|
22
22
|
- **Determinista primero, luego revisión IA-cruzada** — `kj review --staged` pasa SonarQube sobre los ficheros cambiados antes de cualquier opinión de IA (BLOCKER/CRITICAL rechazan en el acto), y después liga el veredicto de una IA *distinta* al sha256 del diff exacto — estampado con el workspace desde el que corrió. Sin veredicto aprobado, **el commit no entra** (gate pre-commit).
|
|
23
23
|
- **Una tercera IA arbitra las disputas** — `kj solomon` decide cuando brain y reviewer discrepan. Los hallazgos de seguridad no los anula nadie — ni siquiera el arbitraje.
|
|
24
24
|
- **Rama primero, carriles para el paralelo** — la rama base solo se mueve por PRs atómicas; `kj worktree start|list|done` da a cada tarea concurrente su carril aislado.
|
|
25
|
+
- **Mínimo privilegio para agentes** — los subprocesos de agente reciben un allowlist de entorno (la auth de su propio CLI, jamás tus claves cloud ni tokens de registro), y `kj check` inventaría cada MCP alcanzable del proyecto marcando lo aparecido desde el último check. Las tareas con superficie sensible se auto-invocan `kj audit --security` — pasada de cero tokens (prompt-injection sobre los ficheros de contexto del agente + OSV + Semgrep + Sonar) — y remedian antes del review.
|
|
26
|
+
- **Nada personal se publica** — cada boundary de salida se audita antes de dejar la máquina: el pre-commit rechaza un diff con tus datos vetados, los tokens de plataforma hardcodeados (`ghp_`, `sk-`, `AKIA`…) bloquean directamente, el scan del tarball guarda el publish, y `kj privacy scan <dir>` audita cualquier build. Tu denylist vive en `~/.karajan/privacy.yml` — la instalación pregunta y la escribe por ti.
|
|
27
|
+
- **Instalar ES activar** — `kj env install` ejecuta él mismo el enforcement (hooks de git, gate de veredicto, tool gate) en vez de confiar en que el agente corra pasos de setup, y termina imprimiendo el método en la propia conversación que instaló. Un commit fuera del método se rechaza, no se narra.
|
|
28
|
+
- **El turno no puede terminar en rojo — el Sentinel** — un supervisor determinista (cero LLM) cableado a los hooks síncronos del harness registra el estado del método de la sesión según corren las herramientas, y un hook Stop bloquea que el agente termine su turno mientras haya violaciones abiertas. El programa manda, el agente piensa. Ver [niveles de garantía](#niveles-de-garantía-gobernado-vs-supervisado).
|
|
25
29
|
|
|
26
30
|
Este repo corre bajo su propio entorno: cada commit de karajan-code lleva un veredicto de IA cruzada.
|
|
27
31
|
|
|
@@ -56,6 +60,12 @@ Requiere git y al menos un CLI de agente de IA — con dos hay revisión cruzada
|
|
|
56
60
|
|
|
57
61
|
Método completo: [Trabaja con tu agente](https://karajancode.com/docs/es/v4/working-with-your-agent/) · [Los gates](https://karajancode.com/docs/es/v4/gates/) · [Referencia de comandos](https://karajancode.com/docs/es/v4/commands/).
|
|
58
62
|
|
|
63
|
+
## Niveles de garantía: gobernado vs supervisado
|
|
64
|
+
|
|
65
|
+
Karajan **gobierna** a cualquier agente con gates de git — el falso verde es estructuralmente imposible escriba quien escriba, porque los gates viven en el repositorio, no en la buena voluntad del agente. Encima de eso, el **Karajan Sentinel** añade **supervisión dentro del turno**: `kj harden` cablea hooks deterministas al harness — uno registra el estado del método de la sesión según corren las herramientas (fuentes editadas vs tests tocados, escapes usados), y un hook Stop bloquea que el agente termine su turno mientras haya violaciones abiertas: fuentes editadas en la rama base, rama sin card, código sin tocar un solo test. Cada bloqueo dice la violación exacta y su remediación; `kj sentinel status` muestra lo que ve el supervisor. Ante cualquier duda falla en abierto — y lo dice — antes que colgar una sesión, y cada escape `KJ_ALLOW_*` queda registrado.
|
|
66
|
+
|
|
67
|
+
Los hooks síncronos con capacidad de bloqueo hoy solo existen en Claude Code. Eso hace explícito el montaje soportado: **para garantizar un harness que controle al LLM, usa Claude Code como anfitrión** — Claude escribe, Codex revisa (un subproceso de revisión no necesita hooks), y un tercer CLI arbitra cuando está disponible. Con cualquier otro anfitrión Karajan sigue gobernando al nivel completo de gates de git y te dice qué nivel está activo — jamás finge una supervisión que no puede imponer.
|
|
68
|
+
|
|
59
69
|
## Modo headless
|
|
60
70
|
|
|
61
71
|
El pipeline multiagente clásico sigue vivo para CI y automatización: `kj run "<tarea>"` orquesta roles coder/reviewer/tester en subprocesos sin humano delante, con los mismos gates. Agentes y CI pasan `--non-interactive` (o `KJ_NON_INTERACTIVE=1`): los gates seguros se auto-responden y los findings FAIL paran el run con exit code de verdad. `kj advanced` lista la superficie completa. [Doc del modo headless](https://karajancode.com/docs/es/v4/headless/).
|
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-check (KJC-TSK-0712) — the release checklist made verifiable:
|
|
3
|
+
* a note loses salience; a check fails RED with the exact list. Generics
|
|
4
|
+
* fit any karajan project; each project extends via `release_check.items`
|
|
5
|
+
* (file_contains with {version}, or command with exit-0 semantics).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
|
|
12
|
+
|
|
13
|
+
const semverCmp = (a, b) => {
|
|
14
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
15
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
|
16
|
+
return 0;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function genericChecks(projectDir) {
|
|
20
|
+
const checks = [];
|
|
21
|
+
const pkgPath = join(projectDir, "package.json");
|
|
22
|
+
if (!existsSync(pkgPath)) {
|
|
23
|
+
checks.push({ name: "manifest", ok: true, detail: "no package.json — generic version checks skipped (declared items still run)" });
|
|
24
|
+
return { checks, version: null, pkg: null };
|
|
25
|
+
}
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
+
const version = pkg.version;
|
|
28
|
+
checks.push({ name: "manifest", ok: Boolean(version), detail: version ? `version ${version}` : "package.json has no version" });
|
|
29
|
+
|
|
30
|
+
const clPath = join(projectDir, "CHANGELOG.md");
|
|
31
|
+
if (!existsSync(clPath)) {
|
|
32
|
+
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
33
|
+
} else {
|
|
34
|
+
const cl = readFileSync(clPath, "utf8");
|
|
35
|
+
const topVersioned = (cl.match(/^## \[(\d+\.\d+\.\d+)\]/m) || [])[1] || null;
|
|
36
|
+
// Content still sitting under [Unreleased] means unpromoted entries —
|
|
37
|
+
// the tarball would ship changes its release notes do not tell.
|
|
38
|
+
const afterUnreleased = cl.split(/^## \[Unreleased\]\s*$/m)[1] ?? "";
|
|
39
|
+
const unreleasedBody = afterUnreleased.split(/^## \[/m)[0] ?? "";
|
|
40
|
+
const pending = /\S/.test(unreleasedBody);
|
|
41
|
+
const ok = topVersioned === version && !pending;
|
|
42
|
+
checks.push({
|
|
43
|
+
name: "changelog-current",
|
|
44
|
+
ok,
|
|
45
|
+
detail: ok
|
|
46
|
+
? `top section is [${version}], Unreleased empty`
|
|
47
|
+
: pending
|
|
48
|
+
? `[Unreleased] still carries content — promote it into [${version}] before releasing`
|
|
49
|
+
: `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tagsOut = await runCommand("git", ["-C", projectDir, "tag", "--list", "v[0-9]*"]);
|
|
54
|
+
const tags = (tagsOut.stdout || "").split("\n").map((t) => t.trim().replace(/^v/, "")).filter((t) => /^\d+\.\d+\.\d+$/.test(t));
|
|
55
|
+
const ahead = version ? tags.filter((t) => semverCmp(t, version) > 0) : [];
|
|
56
|
+
checks.push({
|
|
57
|
+
name: "tags",
|
|
58
|
+
ok: ahead.length === 0,
|
|
59
|
+
detail: ahead.length ? `tag(s) ahead of the manifest exist: v${ahead.join(", v")}` : (tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`),
|
|
60
|
+
});
|
|
61
|
+
return { checks, version, pkg };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function packPrivacyCheck(projectDir, pkg) {
|
|
65
|
+
// Every non-private named package is publishable (exports/files-based
|
|
66
|
+
// packages carry no main/bin) — they all get the scan.
|
|
67
|
+
if (!pkg || pkg.private === true || !pkg.name || !pkg.version) return null;
|
|
68
|
+
try {
|
|
69
|
+
const out = await runCommand("npm", ["pack", "--dry-run", "--json", "--silent"], { cwd: projectDir });
|
|
70
|
+
const files = (JSON.parse(out.stdout || "[]")[0]?.files || []).map((f) => join(projectDir, f.path));
|
|
71
|
+
const blocks = scanPaths(files, { list: loadPrivacyList() }).filter((f) => f.severity === "block");
|
|
72
|
+
return { name: "pack-privacy", ok: blocks.length === 0, detail: blocks.length ? `${blocks.length} personal-data/secret hit(s) in the publishable files` : `${files.length} publishable file(s) clean` };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { name: "pack-privacy", ok: false, detail: `npm pack --dry-run failed: ${err.message}` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function declaredItems(projectDir, config, version) {
|
|
79
|
+
const items = config?.release_check?.items;
|
|
80
|
+
if (!Array.isArray(items)) return [];
|
|
81
|
+
const checks = [];
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const name = item?.name || "unnamed item";
|
|
84
|
+
if (item?.file_contains?.path && item.file_contains.pattern != null) {
|
|
85
|
+
const p = isAbsolute(item.file_contains.path) ? item.file_contains.path : join(projectDir, item.file_contains.path);
|
|
86
|
+
const needle = String(item.file_contains.pattern).replaceAll("{version}", version ?? "");
|
|
87
|
+
const ok = existsSync(p) && readFileSync(p, "utf8").includes(needle);
|
|
88
|
+
checks.push({ name, ok, detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
|
|
89
|
+
} else if (item?.command) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await runCommand("sh", ["-c", String(item.command).replaceAll("{version}", version ?? "")], { cwd: projectDir });
|
|
92
|
+
checks.push({ name, ok: res.exitCode === 0, detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
checks.push({ name, ok: false, detail: `command failed to run: ${err.message}` });
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
checks.push({ name, ok: false, detail: "unknown item shape — use file_contains {path, pattern} or command" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return checks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
|
|
104
|
+
const { checks, version, pkg } = await genericChecks(projectDir);
|
|
105
|
+
const pack = await packPrivacyCheck(projectDir, pkg);
|
|
106
|
+
if (pack) checks.push(pack);
|
|
107
|
+
checks.push(...await declaredItems(projectDir, config, version));
|
|
108
|
+
return { ok: checks.every((c) => c.ok), version, checks };
|
|
109
|
+
}
|
|
@@ -30,8 +30,8 @@ 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", "privacy"] },
|
|
34
|
-
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release"] },
|
|
34
|
+
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
|
37
37
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mutateCommand } from "../commands/mutate.js";
|
|
|
20
20
|
import { hardenCommand } from "../commands/harden.js";
|
|
21
21
|
import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
|
|
22
22
|
import { envInstallCommand, briefCommand } from "../commands/env.js";
|
|
23
|
+
import { runReleaseCheck } from "../checks/release-check.js";
|
|
23
24
|
import { agentRunCommand } from "../commands/agent-run.js";
|
|
24
25
|
import { reportIssueCommand } from "../commands/report-issue.js";
|
|
25
26
|
import { huCommand } from "../commands/hu.js";
|
|
@@ -27,6 +28,10 @@ import { worktreeCommand } from "../commands/worktree.js";
|
|
|
27
28
|
import { addAdr, listAdrs } from "../environment/adr.js";
|
|
28
29
|
import { formatAdvancedIndex } from "../commands/advanced.js";
|
|
29
30
|
import { withConfig } from "./_shared.js";
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-hooks.js";
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* Register the "meta" / single-role / housekeeping commands: pre-pipeline
|
|
@@ -262,6 +267,46 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
262
267
|
});
|
|
263
268
|
});
|
|
264
269
|
|
|
270
|
+
// KJC-TSK-0712 — memory is the reminder, the check is the guarantee.
|
|
271
|
+
const release = program.command("release").description("Release ceremony helpers");
|
|
272
|
+
release.command("check")
|
|
273
|
+
.description("Verify the release checklist deterministically (manifest vs CHANGELOG vs tags, privacy scan of publishable files, plus the project's release_check.items); exit 1 lists exactly what is missing")
|
|
274
|
+
.option("--json", "Machine-readable result")
|
|
275
|
+
.action(async (flags) => {
|
|
276
|
+
await withConfig(pkgVersion, "release-check", flags, async ({ config }) => {
|
|
277
|
+
const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config });
|
|
278
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
279
|
+
else {
|
|
280
|
+
for (const c of res.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
|
|
281
|
+
console.log(res.ok ? `release check: ready to release ${res.version ?? ""}`.trim() : "release check: NOT ready — fix the red items first");
|
|
282
|
+
}
|
|
283
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// KJC-TSK-0713 — the Sentinel: the method state the harness hooks record.
|
|
288
|
+
const sentinel = program.command("sentinel").description("Deterministic method supervisor wired into the harness hooks (Claude Code)");
|
|
289
|
+
sentinel.command("status")
|
|
290
|
+
.description("Print the per-session method facts (sources/tests edited, escapes used) and any open violation the Stop gate would block on")
|
|
291
|
+
.action(() => {
|
|
292
|
+
const script = join(resolveSentinelRoot(), ".karajan", "harness", "stop.mjs");
|
|
293
|
+
if (!existsSync(script)) {
|
|
294
|
+
console.log("sentinel: not installed in this project — run `kj harden` (standard profile or higher)");
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
process.exitCode = spawnSync("node", [script, "--status"], { stdio: "inherit" }).status ?? 0;
|
|
299
|
+
});
|
|
300
|
+
sentinel.command("verify")
|
|
301
|
+
.description("Verify the harness scripts match this kj install — the root of trust is the installed package, not the project tree; exit 1 lists what was modified")
|
|
302
|
+
.option("--json", "Machine-readable result")
|
|
303
|
+
.action((flags) => {
|
|
304
|
+
const res = verifySentinelScripts();
|
|
305
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
306
|
+
else console.log(res.ok ? "sentinel verify: scripts intactos" : `sentinel verify: modificados fuera de kj harden: ${res.mismatched.join(", ")} — restaura con kj harden`);
|
|
307
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
308
|
+
});
|
|
309
|
+
|
|
265
310
|
// KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
|
|
266
311
|
const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
|
|
267
312
|
privacy.command("scan [paths...]")
|
package/src/commands/harden.js
CHANGED
|
@@ -19,6 +19,7 @@ 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
21
|
import { installHarnessHooks } from "../harden/harness-hooks.js";
|
|
22
|
+
import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
22
23
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
23
24
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
24
25
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
@@ -146,6 +147,10 @@ export async function hardenCommand({
|
|
|
146
147
|
if (profile !== "minimal" && !dryRun) {
|
|
147
148
|
const hh = installHarnessHooks({ projectDir, logger });
|
|
148
149
|
result.harnessHooks = hh.wired ? "wired" : "script-only";
|
|
150
|
+
// KJC-TSK-0713 — the Sentinel: method state + Stop gate (turn cannot
|
|
151
|
+
// end red). Same Claude-only harness surface as the tool gate.
|
|
152
|
+
const sh = installSentinelHooks({ projectDir, logger });
|
|
153
|
+
result.sentinelHooks = sh.wired ? "wired" : "script-only";
|
|
149
154
|
}
|
|
150
155
|
} catch (err) {
|
|
151
156
|
if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
|
package/src/commands/report.js
CHANGED
|
@@ -162,6 +162,9 @@ async function buildReport(dir, sessionId) {
|
|
|
162
162
|
if (session.pg_task_id) report.pg_task_id = session.pg_task_id;
|
|
163
163
|
if (session.pg_project_id) report.pg_project_id = session.pg_project_id;
|
|
164
164
|
if (session.rtk_savings) report.rtk_savings = session.rtk_savings;
|
|
165
|
+
// The session may belong to another workspace: its snapshot knows the
|
|
166
|
+
// project dir the sentinel state lives in (same source solomon-rules uses).
|
|
167
|
+
if (session.config_snapshot?.projectDir) report.project_dir = session.config_snapshot.projectDir;
|
|
165
168
|
return report;
|
|
166
169
|
}
|
|
167
170
|
|
|
@@ -210,6 +213,36 @@ function printTextReport(report) {
|
|
|
210
213
|
console.log(formatCommitsText(report.commits_generated));
|
|
211
214
|
}
|
|
212
215
|
|
|
216
|
+
/**
|
|
217
|
+
* KJC-TSK-0715 — escapes are the user's decisions, never the agent's silent
|
|
218
|
+
* shortcuts: every KJ_ALLOW_* the sentinel honored surfaces in the report.
|
|
219
|
+
*/
|
|
220
|
+
export function formatSentinelEscapes(state) {
|
|
221
|
+
const events = Array.isArray(state?.escape_events) ? state.escape_events : [];
|
|
222
|
+
if (events.length === 0) return null;
|
|
223
|
+
const lines = events.map(
|
|
224
|
+
(e) => ` ${e.ts ? new Date(e.ts).toISOString() : "?"} ${e.escape} (tool: ${e.tool || "?"}, session: ${e.sid || "?"})`
|
|
225
|
+
);
|
|
226
|
+
return `Sentinel escapes used (${events.length}):\n${lines.join("\n")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The sentinel state is a PROJECT artifact (<project>/.karajan/harness), not
|
|
230
|
+
// a session artifact: the `dir` the report handlers receive is the GLOBAL
|
|
231
|
+
// sessions root (~/.karajan/sessions) and never contains it. `kj report` is
|
|
232
|
+
// run from inside the project, so the project dir is the cwd — overridable
|
|
233
|
+
// for callers reporting on another workspace.
|
|
234
|
+
async function printSentinelEscapes({ projectDir = process.cwd(), format } = {}) {
|
|
235
|
+
if (format === "json") return;
|
|
236
|
+
try {
|
|
237
|
+
const raw = await fs.readFile(path.join(projectDir, ".karajan", "harness", "sentinel-state.json"), "utf8");
|
|
238
|
+
const text = formatSentinelEscapes(JSON.parse(raw));
|
|
239
|
+
if (text) {
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log(text);
|
|
242
|
+
}
|
|
243
|
+
} catch { /* no sentinel state in this project — nothing to report */ }
|
|
244
|
+
}
|
|
245
|
+
|
|
213
246
|
function formatDuration(ms) {
|
|
214
247
|
if (ms === null || ms === undefined) return "-";
|
|
215
248
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -384,6 +417,7 @@ async function handlePgTaskReport({ dir, pgTask, list, sessionId, format, trace,
|
|
|
384
417
|
} else {
|
|
385
418
|
printTextReport(report);
|
|
386
419
|
}
|
|
420
|
+
await printSentinelEscapes({ projectDir: report.project_dir, format });
|
|
387
421
|
}
|
|
388
422
|
|
|
389
423
|
async function handleSingleSessionReport({ dir, entries, sessionId, format, trace, currency }) {
|
|
@@ -405,9 +439,10 @@ async function handleSingleSessionReport({ dir, entries, sessionId, format, trac
|
|
|
405
439
|
if (trace) {
|
|
406
440
|
const { cur, rate } = await resolveTraceOptions(currency);
|
|
407
441
|
printTraceReport(report, cur, rate);
|
|
408
|
-
|
|
442
|
+
} else {
|
|
443
|
+
printTextReport(report);
|
|
409
444
|
}
|
|
410
|
-
|
|
445
|
+
await printSentinelEscapes({ projectDir: report.project_dir, format });
|
|
411
446
|
}
|
|
412
447
|
|
|
413
448
|
export async function reportCommand({ list = false, sessionId = null, format = "text", trace = false, currency = "usd", pgTask = null }) {
|
|
@@ -12,8 +12,6 @@
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
-
const SCRIPT_REL = join(".karajan", "harness", "pretooluse.mjs");
|
|
16
|
-
|
|
17
15
|
const SCRIPT_BODY = `#!/usr/bin/env node
|
|
18
16
|
// kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
|
|
19
17
|
// tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
|
|
@@ -43,38 +41,67 @@ process.stdin.on("end", () => {
|
|
|
43
41
|
});
|
|
44
42
|
`;
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
/** Write a managed hook script under .karajan/harness/ and return its path. */
|
|
45
|
+
export function writeHarnessScript(projectDir, name, body) {
|
|
46
|
+
const dir = join(projectDir, ".karajan", "harness");
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
const abs = join(dir, name);
|
|
49
|
+
writeFileSync(abs, body, { mode: 0o755 });
|
|
50
|
+
return abs;
|
|
51
|
+
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Merge hook entries into .claude/settings.json. Preserve, never clobber:
|
|
55
|
+
* invalid JSON or a hook event with an unexpected (non-array) shape is the
|
|
56
|
+
* user's business — the file is left alone and the caller wires manually.
|
|
57
|
+
* entries: [{ event, matcher?, script }] where script is a .karajan/harness
|
|
58
|
+
* basename; an entry is present when that script already appears under the
|
|
59
|
+
* same event (and matcher, when given).
|
|
60
|
+
*/
|
|
61
|
+
export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
|
|
54
62
|
const settingsPath = join(projectDir, ".claude", "settings.json");
|
|
55
63
|
let settings = {};
|
|
56
64
|
if (existsSync(settingsPath)) {
|
|
57
65
|
try {
|
|
58
66
|
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
59
67
|
} catch {
|
|
60
|
-
logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (
|
|
61
|
-
return {
|
|
68
|
+
logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (hook scripts written, wire them manually)`);
|
|
69
|
+
return { wired: false };
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
settings.hooks = settings.hooks || {};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
for (const { event } of entries) {
|
|
74
|
+
if (event in settings.hooks && !Array.isArray(settings.hooks[event])) {
|
|
75
|
+
logger.warn?.(`kj harden: ${settingsPath} has a non-array hooks.${event} — leaving it untouched (hook scripts written, wire them manually)`);
|
|
76
|
+
return { wired: false };
|
|
77
|
+
}
|
|
70
78
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const present =
|
|
74
|
-
|
|
79
|
+
for (const { event, matcher, script } of entries) {
|
|
80
|
+
const list = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
81
|
+
const present = list.some(
|
|
82
|
+
(e) => JSON.stringify(e).includes(script) && (matcher === undefined || e?.matcher === matcher),
|
|
83
|
+
);
|
|
84
|
+
if (!present) {
|
|
85
|
+
const cmd = { type: "command", command: `node ${join(".karajan", "harness", script)}` };
|
|
86
|
+
list.push(matcher === undefined ? { hooks: [cmd] } : { matcher, hooks: [cmd] });
|
|
87
|
+
}
|
|
88
|
+
settings.hooks[event] = list;
|
|
75
89
|
}
|
|
76
|
-
settings.hooks.PreToolUse = pre;
|
|
77
90
|
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
78
91
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
79
|
-
return {
|
|
92
|
+
return { wired: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Write the script and merge the PreToolUse entries into .claude/settings.json. */
|
|
96
|
+
export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
|
|
97
|
+
const scriptAbs = writeHarnessScript(projectDir, "pretooluse.mjs", SCRIPT_BODY);
|
|
98
|
+
const { wired } = mergeClaudeHooks({
|
|
99
|
+
projectDir,
|
|
100
|
+
logger,
|
|
101
|
+
entries: [
|
|
102
|
+
{ event: "PreToolUse", matcher: "Write", script: "pretooluse.mjs" },
|
|
103
|
+
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse.mjs" },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
return { script: scriptAbs, wired };
|
|
80
107
|
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sentinel-hooks — SEN-A (KJC-TSK-0713, epic KJC-PCS-0071 Karajan Sentinel).
|
|
3
|
+
* v3 had authority without intelligence; v4 intelligence without authority.
|
|
4
|
+
* The Sentinel separates them: a deterministic PROGRAM (zero LLM) supervises
|
|
5
|
+
* the agent through the harness's synchronous hooks — PostToolUse records
|
|
6
|
+
* method facts per session, Stop BLOCKS ending the turn while violations are
|
|
7
|
+
* open. Claude Code only: it is the one harness with synchronous blocking
|
|
8
|
+
* hooks, which is why the guaranteed level requires Claude as host (ADR).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { CARD_REF_RE } from "../review/card-first.js";
|
|
15
|
+
import { mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
|
|
16
|
+
|
|
17
|
+
/** The harness lives at the PROJECT root — resolve it even from a subdir. */
|
|
18
|
+
export function resolveSentinelRoot(dir = process.cwd()) {
|
|
19
|
+
try {
|
|
20
|
+
return (
|
|
21
|
+
execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
|
|
29
|
+
// Single source for every sentinel script: state, branch, classification,
|
|
30
|
+
// violations, and escape recording.
|
|
31
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { execSync } from "node:child_process";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
export const STATE = join(here, "sentinel-state.json");
|
|
37
|
+
export const ROOT = join(here, "..", "..");
|
|
38
|
+
export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$/;
|
|
39
|
+
export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp)$/;
|
|
40
|
+
export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
|
|
41
|
+
export const BASE_BRANCHES = new Set(["main", "master"]);
|
|
42
|
+
export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
43
|
+
export const save = (state) => writeFileSync(STATE, JSON.stringify(state, null, 2));
|
|
44
|
+
export const session = (state, sid) => {
|
|
45
|
+
state.sessions ||= {};
|
|
46
|
+
return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
|
|
47
|
+
};
|
|
48
|
+
export const branchOf = () => {
|
|
49
|
+
try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
|
|
50
|
+
catch { return null; }
|
|
51
|
+
};
|
|
52
|
+
export const violations = (s, branch) => {
|
|
53
|
+
const v = [];
|
|
54
|
+
if (!s || !(s.edited_sources || []).length) return v;
|
|
55
|
+
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
56
|
+
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
57
|
+
if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
58
|
+
return v;
|
|
59
|
+
};
|
|
60
|
+
export const recordEscape = (sid, escape, tool) => {
|
|
61
|
+
const state = load();
|
|
62
|
+
const s = session(state, sid);
|
|
63
|
+
s.at = Date.now();
|
|
64
|
+
if (!s.escapes.includes(escape)) s.escapes.push(escape);
|
|
65
|
+
(state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
|
|
66
|
+
save(state);
|
|
67
|
+
};
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const POST_BODY = `#!/usr/bin/env node
|
|
71
|
+
// kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
|
|
72
|
+
// Records deterministic method facts per session; never blocks, never fails
|
|
73
|
+
// a tool call (PostToolUse, always exit 0).
|
|
74
|
+
import { relative } from "node:path";
|
|
75
|
+
import { CODE, TESTS, ROOT, load, save, session } from "./sentinel-lib.mjs";
|
|
76
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII"];
|
|
77
|
+
let raw = "";
|
|
78
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
79
|
+
process.stdin.on("end", () => {
|
|
80
|
+
try {
|
|
81
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
82
|
+
const file = input.file_path || input.notebook_path;
|
|
83
|
+
if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
|
|
84
|
+
const state = load();
|
|
85
|
+
const s = session(state, sid);
|
|
86
|
+
s.at = Date.now();
|
|
87
|
+
const rel = relative(ROOT, file).replaceAll("\\\\", "/");
|
|
88
|
+
const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
|
|
89
|
+
if (bucket && !bucket.includes(rel)) bucket.push(rel);
|
|
90
|
+
for (const e of ESCAPES)
|
|
91
|
+
if (process.env[e] === "1" && !s.escapes.includes(e)) {
|
|
92
|
+
s.escapes.push(e);
|
|
93
|
+
(state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
|
|
94
|
+
}
|
|
95
|
+
const ids = Object.keys(state.sessions);
|
|
96
|
+
if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
|
|
97
|
+
save(state);
|
|
98
|
+
} catch { /* fail open — the sentinel never breaks a tool call */ }
|
|
99
|
+
process.exit(0);
|
|
100
|
+
});
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const STOP_BODY = `#!/usr/bin/env node
|
|
104
|
+
// kj sentinel stop gate (KJC-TSK-0713) — managed by \`kj harden\`. Exit 2
|
|
105
|
+
// BLOCKS ending the turn while method violations are open (stderr lists each
|
|
106
|
+
// one with its remediation). Fails OPEN on corrupt state, git errors, or
|
|
107
|
+
// after 3 unresolved blocks — a sentinel bug never bricks the session — and
|
|
108
|
+
// the fail-open is recorded in the state. \`--status\` prints, never blocks.
|
|
109
|
+
import { spawnSync } from "node:child_process";
|
|
110
|
+
import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
111
|
+
if (process.argv.includes("--status")) {
|
|
112
|
+
const st = load();
|
|
113
|
+
const branch = branchOf();
|
|
114
|
+
const sessions = Object.entries(st.sessions || {});
|
|
115
|
+
if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
|
|
116
|
+
for (const [sid, s] of sessions) {
|
|
117
|
+
console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : ""));
|
|
118
|
+
for (const x of violations(s, branch)) console.log(" ROJO: " + x);
|
|
119
|
+
}
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
let raw = "";
|
|
123
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
124
|
+
process.stdin.on("end", () => {
|
|
125
|
+
try {
|
|
126
|
+
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
127
|
+
const { session_id: sid = "default" } = JSON.parse(raw);
|
|
128
|
+
const state = load();
|
|
129
|
+
// Tamper check runs regardless of session state: shell indirection leaves
|
|
130
|
+
// no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
|
|
131
|
+
// root of trust is the INSTALLED kj package (outside this project tree) —
|
|
132
|
+
// nothing under .karajan can vouch for itself. No kj on PATH → fail open.
|
|
133
|
+
const ver = spawnSync("kj", ["sentinel", "verify", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
134
|
+
let tampered = [];
|
|
135
|
+
if (!ver.error && ver.status !== null && ver.status !== 0) {
|
|
136
|
+
try { tampered = JSON.parse(ver.stdout).mismatched || ["unknown"]; } catch { tampered = ["unknown"]; }
|
|
137
|
+
}
|
|
138
|
+
if (tampered.length) {
|
|
139
|
+
state.tamper_blocks = (state.tamper_blocks || 0) + 1;
|
|
140
|
+
save(state);
|
|
141
|
+
if (state.tamper_blocks > 3) {
|
|
142
|
+
console.error("kj sentinel: fail-open tras 3 bloqueos por manipulacion sin restaurar — corre kj harden y avisa a tu usuario");
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
console.error("kj sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde.");
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
state.tamper_blocks = 0;
|
|
149
|
+
const s = state.sessions?.[sid];
|
|
150
|
+
if (!s) process.exit(0);
|
|
151
|
+
const v = violations(s, branchOf());
|
|
152
|
+
if (!v.length) {
|
|
153
|
+
s.blocks = 0;
|
|
154
|
+
save(state);
|
|
155
|
+
if ((s.escapes || []).length)
|
|
156
|
+
console.log(JSON.stringify({ systemMessage: "kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status." }));
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
s.blocks = (s.blocks || 0) + 1;
|
|
160
|
+
if (s.blocks > 3) {
|
|
161
|
+
(s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
|
|
162
|
+
save(state);
|
|
163
|
+
console.error("kj sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario");
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
save(state);
|
|
167
|
+
console.error("kj sentinel: el turno NO puede terminar con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status");
|
|
168
|
+
process.exit(2);
|
|
169
|
+
} catch { /* fail open */ }
|
|
170
|
+
process.exit(0);
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
const PRETOOL_BODY = `#!/usr/bin/env node
|
|
175
|
+
// kj sentinel pretooluse gate (KJC-TSK-0714) — managed by \`kj harden\`.
|
|
176
|
+
// Consults the session method state BEFORE the tool runs: the rule fires
|
|
177
|
+
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
178
|
+
// remediation); read-only tools are never wired here; every honored escape
|
|
179
|
+
// is recorded as an auditable event. Fails OPEN on anything unexpected.
|
|
180
|
+
import { relative } from "node:path";
|
|
181
|
+
import { spawnSync } from "node:child_process";
|
|
182
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, load, violations, recordEscape } from "./sentinel-lib.mjs";
|
|
183
|
+
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
184
|
+
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
185
|
+
const PUSH = /\\bgit\\s+push\\b/;
|
|
186
|
+
const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
|
|
187
|
+
let raw = "";
|
|
188
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
189
|
+
process.stdin.on("end", () => {
|
|
190
|
+
try {
|
|
191
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
192
|
+
// Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
|
|
193
|
+
// KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
|
|
194
|
+
// only the human, editing outside it.
|
|
195
|
+
if (EDIT_TOOLS.includes(tool)) {
|
|
196
|
+
const target = input.file_path || input.notebook_path;
|
|
197
|
+
const relT = target ? relative(ROOT, String(target)).replaceAll("\\\\", "/") : "";
|
|
198
|
+
if (relT && PROTECTED.test(relT)) {
|
|
199
|
+
console.error("kj sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion.");
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Any Bash that NAMES the supervisor's files is denied — a write-verb
|
|
204
|
+
// blocklist is bypassable (cp, dd, one-liners), and reading them is what
|
|
205
|
+
// the Read/Grep tools are for. Shell indirection (variables, globs,
|
|
206
|
+
// substitution) cannot be resolved from command text: that vector is
|
|
207
|
+
// caught by the Stop gate's checksum verification, which blocks the turn.
|
|
208
|
+
if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\\\", "/"))) {
|
|
209
|
+
console.error("kj sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion.");
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
212
|
+
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
213
|
+
if (EDIT_TOOLS.includes(tool)) {
|
|
214
|
+
const file = input.file_path || input.notebook_path;
|
|
215
|
+
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
216
|
+
if (rel && CODE.test(rel) && !TESTS.test(rel)) {
|
|
217
|
+
const branch = branchOf();
|
|
218
|
+
const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
|
|
219
|
+
if (why) {
|
|
220
|
+
if (process.env.KJ_ALLOW_NO_CARD === "1") { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
|
|
221
|
+
console.error(why === "base"
|
|
222
|
+
? "kj sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)"
|
|
223
|
+
: "kj sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)");
|
|
224
|
+
process.exit(2);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (tool === "Bash") {
|
|
229
|
+
const cmd = String(input.command || "");
|
|
230
|
+
if (PUBLISH.test(cmd)) {
|
|
231
|
+
if (process.env.KJ_ALLOW_RELEASE === "1") { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
232
|
+
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
233
|
+
if (res.error || res.status === null) process.exit(0);
|
|
234
|
+
if (res.status !== 0) {
|
|
235
|
+
let items = "";
|
|
236
|
+
try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
|
|
237
|
+
console.error("kj sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)");
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
240
|
+
} else if (PUSH.test(cmd)) {
|
|
241
|
+
const v = violations(load().sessions?.[sid], branchOf());
|
|
242
|
+
if (v.length) {
|
|
243
|
+
console.error("kj sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status");
|
|
244
|
+
process.exit(2);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch { /* fail open */ }
|
|
249
|
+
process.exit(0);
|
|
250
|
+
});
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
const SCRIPT_BODIES = {
|
|
254
|
+
"sentinel-lib.mjs": LIB_BODY,
|
|
255
|
+
"posttooluse.mjs": POST_BODY,
|
|
256
|
+
"stop.mjs": STOP_BODY,
|
|
257
|
+
"pretooluse-sentinel.mjs": PRETOOL_BODY,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Tamper detection (KJC-TSK-0715): compare the on-disk harness scripts with
|
|
262
|
+
* what THIS kj install would write. The root of trust is the installed kj
|
|
263
|
+
* package — outside the project tree, beyond the session's tool reach —
|
|
264
|
+
* because nothing under .karajan can vouch for itself.
|
|
265
|
+
*/
|
|
266
|
+
export function verifySentinelScripts({ projectDir } = {}) {
|
|
267
|
+
const dir = join(projectDir || resolveSentinelRoot(), ".karajan", "harness");
|
|
268
|
+
const mismatched = [];
|
|
269
|
+
for (const [name, body] of Object.entries(SCRIPT_BODIES)) {
|
|
270
|
+
try {
|
|
271
|
+
if (readFileSync(join(dir, name), "utf8") !== body) mismatched.push(name);
|
|
272
|
+
} catch {
|
|
273
|
+
mismatched.push(name);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { ok: mismatched.length === 0, mismatched };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Write the sentinel scripts (shared lib + state writer + gates) and wire them. */
|
|
280
|
+
export function installSentinelHooks({ projectDir = process.cwd(), logger = console } = {}) {
|
|
281
|
+
const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) =>
|
|
282
|
+
writeHarnessScript(projectDir, name, body),
|
|
283
|
+
);
|
|
284
|
+
const { wired } = mergeClaudeHooks({
|
|
285
|
+
projectDir,
|
|
286
|
+
logger,
|
|
287
|
+
entries: [
|
|
288
|
+
{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
|
|
289
|
+
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
|
|
290
|
+
{ event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
|
|
291
|
+
{ event: "Stop", script: "stop.mjs" },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
return { scripts: [lib, post, stop, pre], wired };
|
|
295
|
+
}
|