diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1abb3718..046570a3 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -5,11 +5,15 @@ # means `git commit --no-verify` skipped the gate. __kit_git_dir=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} if [ -n "$__kit_git_dir" ]; then - date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/.kit-hook-ran" 2>/dev/null || true + { date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/.kit-hook-ran"; } 2>/dev/null || true fi # pre-commit — block internal references in staged content before they land. # Term list is resolved outside this repo (see no-internal-leaks.sh header). "$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --staged || exit 1 + +kit security scan-staged || exit 1 +npm run build || exit 1 + # Receipt for kit's post-commit skip-detector (.kit-hook-ran): prove that # pre-commit actually ran, so honest commits stop logging as "bypassed". -date -u +"%Y-%m-%dT%H:%M:%SZ" > "$(git rev-parse --git-dir)/.kit-hook-ran" 2>/dev/null || true +{ date -u +"%Y-%m-%dT%H:%M:%SZ" > "$(git rev-parse --git-dir)/.kit-hook-ran"; } 2>/dev/null || true diff --git a/.githooks/pre-push b/.githooks/pre-push index c7a1e780..e5c7086b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,4 +1,5 @@ #!/usr/bin/env sh # pre-push — last gate: scan every tracked file for internal references # before anything reaches the public remote. -exec "$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --tracked +"$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --tracked || exit 1 +npm audit --audit-level=high || exit 1 diff --git a/.kit.toml b/.kit.toml index a820902b..ad793cc6 100644 --- a/.kit.toml +++ b/.kit.toml @@ -12,7 +12,7 @@ pnpm = "latest" "aqua:trufflesecurity/trufflehog" = "latest" [hooks] -pre-commit = ["kit security scan-staged", "npm run build", "npm test"] +pre-commit = ["kit security scan-staged", "npm run build"] pre-push = ["npm audit --audit-level=high"] # Scanner tokens (SNYK_TOKEN, …) resolve from the shared sandstream-common diff --git a/.kit/shared/memory.jsonl b/.kit/shared/memory.jsonl index 64f70ca2..e68f63a8 100644 --- a/.kit/shared/memory.jsonl +++ b/.kit/shared/memory.jsonl @@ -44,3 +44,4 @@ {"id":"19e5ae","area":"cli","kind":"decision","title":"kit's own repo does not require a decision ledger","body":"[decisions] require = true is deliberately NOT set in kit's own .kit.toml. The ledger is a per-run artifact produced by an agent-governed run; on a plain CI checkout no agent ran, so requiring one there would gate on the absence of something nothing was asked to produce. require = true belongs on agent-governed runs, not on every checkout. Revisit if kit's CI ever runs an agent that records decisions.","refs":[],"author":"Peter Sandström ","ts":"2026-08-24T11:36:15.116Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"GVK6jNW1XjLU3q/ESBdScGkB3zNdVdmkI9q1Lac7u+lKiu3hfUsHQPK5SZYLykEk0FagMOgwcO4X4PrXxQ2UDw=="} {"id":"9c7fa6","area":"cli","kind":"convention","title":"A config section is declared in three places, and the third one warns","body":"Adding a .kit.toml section means kitConfig (type), CONFIG_SECTIONS (config-surface.ts, generates docs/CONFIGURATION.md) AND KNOWN_SECTIONS (config.ts), which loadConfig warns from. The first two were pinned to each other; the third had drifted by two — [supply_chain] and [coverage] are real, honoured sections that printed 'unknown section … (likely a typo)' on every kit invocation. A warning that fires on correct configuration trains the operator to ignore the one that fires on a real typo. config-surface.test.ts now pins all three in both directions.","refs":[],"author":"Peter Sandström ","ts":"2026-08-24T11:36:15.667Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"0D2CmPQKGkbvtwM5zfM80w3jUa1h1F0AY8Nit6EFMzREVdIbI/PU+ULLVgERFb8G+f2zw46CBt1jSk4qyDAnCw=="} {"id":"f47c5a","area":"cli","kind":"convention","title":"A gate that exists but is never invoked is the default failure, not the exception","body":"kit adopted its own ADR gate in #403 and no workflow, hook or agent instruction ever called it — armed and unfired for a month, while the rules provably caught violations. A gate nobody runs emits nothing, and nothing reads exactly like a clean run. self-audit-ci already proves every script a workflow points AT exists; the inverse (a gate that exists is pointed at by something) had no rule. When adding a gate to this repo, wire the invocation AND pin it with a test that strips comments and forbids continue-on-error / || true — a gate named in a comment is not a gate, and one that cannot fail the build is a report. General case tracked in #533.","refs":[],"author":"Peter Sandström ","ts":"2026-08-25T12:06:22.827Z","source_ref":"a49e85c","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"CNhZy+Of23FnzPSF6O95FvxAriwbLWBumewW/9QtgPN606vE4IiidXydMmKQj7JyfBJPOWRBCZM1c7QoSIr4Dw=="} +{"id":"d1814d","area":"cli","kind":"decision","title":"pre-commit excludes full npm test until suite timeouts are fixed","body":"kit-public uses externally managed .githooks. [hooks].pre-commit should require staged security scan + build, not full npm test: a real pre-commit run on 2026-08-27 hit Node test file timeouts in dist/policy-gate.test.js and dist/secrets-propagate.test.js. Re-add full npm test only after those suite timeouts are fixed or the suite is split for hook use.","refs":[],"author":"Peter Sandström ","ts":"2026-08-27T09:22:36.795Z","source_ref":"3cfa838","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"bbdKUzm6w6dIFEacsfFMdkuLhhwBjZ732zvs08X0ZPlv6NQ7J3CgDJ2GpZhf+M8k7Yff8gvlpPej4b+sYHWOBA=="} diff --git a/src/check-hooks.test.ts b/src/check-hooks.test.ts index ee81a61f..ffbb05b7 100644 --- a/src/check-hooks.test.ts +++ b/src/check-hooks.test.ts @@ -66,13 +66,25 @@ describe("checkHooks", () => { assert.ok(results[0].detail.includes("not installed")); }); - it("reports not managed by kit when hook lacks the marker", async () => { + it("accepts externally managed hooks when configured commands are present", async () => { await mkdir(join(gitDir, "hooks"), { recursive: true }); await writeFile(join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\nnpm test\n", "utf-8"); const config: HooksConfig = { "pre-commit": ["npm test"] }; const results = await checkHooks(config); + assert.equal(results[0].installed, true); + assert.equal(results[0].upToDate, true); + assert.ok(results[0].detail.includes("externally managed")); + }); + + it("reports not managed by kit when external hook misses configured commands", async () => { + await mkdir(join(gitDir, "hooks"), { recursive: true }); + await writeFile(join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho manual\n", "utf-8"); + + const config: HooksConfig = { "pre-commit": ["npm test"] }; + const results = await checkHooks(config); + assert.equal(results[0].installed, true); assert.equal(results[0].upToDate, false); assert.ok(results[0].detail.includes("not managed by kit")); diff --git a/src/check-hooks.ts b/src/check-hooks.ts index fb984856..ac3b3d09 100644 --- a/src/check-hooks.ts +++ b/src/check-hooks.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; import type { HooksConfig } from "./config.js"; +import { missingHookCommands, resolveHooksDir } from "./hooks.js"; export interface HookCheckResult { hookName: string; @@ -22,7 +23,6 @@ export async function checkHooks( // The SAME resolver the writer uses. Hardcoding `/hooks` here meant that with an // external `core.hooksPath`, kit installed to one directory and reported on another: the // declared hooks read as "not installed" while they were installed and firing (#496). - const { resolveHooksDir } = await import("./hooks.js"); const hooksDir = resolveHooksDir(gitDir, cwd); for (const [hookName, commands] of Object.entries(config)) { @@ -61,19 +61,22 @@ async function checkHook( // Read hook content const content = await readFile(hookPath, "utf-8"); - // Check if it's a kit-generated hook + const missing = missingHookCommands(content, commands); + const upToDate = missing.length === 0; + + // A Husky/Lefthook/.githooks-style hook can satisfy the contract too, as + // long as it actually runs the commands declared in [hooks]. if (!content.includes("# Generated by kit")) { return { hookName, installed: true, - upToDate: false, - detail: "not managed by kit", + upToDate, + detail: upToDate + ? `externally managed; ${commands.length} configured command(s) present` + : `not managed by kit; missing ${missing.length} configured command(s)`, }; } - // Check if commands match - const upToDate = commands.every((cmd) => content.includes(cmd)); - return { hookName, installed: true, diff --git a/src/cli.test.ts b/src/cli.test.ts index 2608a3af..6875a90f 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -441,6 +441,35 @@ describe("kit fix", () => { assert.match(result.stdout, /Agenten fortsätter med: kit check --category services,secrets/); }); + it("reports non-kit hooks under core.hooksPath as manual, not fixed", async () => { + await exec("git", ["init", "-q"], { cwd: tempDir }); + await exec("git", ["config", "core.hooksPath", ".githooks"], { cwd: tempDir }); + await mkdir(join(tempDir, ".githooks"), { recursive: true }); + await writeFile(join(tempDir, ".githooks", "pre-commit"), "#!/bin/sh\necho manual\n", "utf-8"); + await writeFile(join(tempDir, ".githooks", "pre-push"), "#!/bin/sh\necho manual\n", "utf-8"); + await chmod(join(tempDir, ".githooks", "pre-commit"), 0o755); + await chmod(join(tempDir, ".githooks", "pre-push"), 0o755); + await writeFile( + join(tempDir, ".kit.toml"), + '[hooks]\npre-commit = ["kit security scan-staged"]\npre-push = ["npm audit --audit-level=high"]\n', + "utf-8", + ); + + const result = await runCli(["fix"], tempDir); + + assert.equal(result.exitCode, 1, `stdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.match(result.stdout, /pre-commit hook was left untouched/); + assert.match(result.stdout, /pre-push hook was left untouched/); + assert.match(result.stdout, /require human action/); + + const preCommit = await readFile(join(tempDir, ".githooks", "pre-commit"), "utf-8"); + const prePush = await readFile(join(tempDir, ".githooks", "pre-push"), "utf-8"); + assert.doesNotMatch(preCommit, /# Generated by kit/); + assert.doesNotMatch(preCommit, /kit security scan-staged/); + assert.doesNotMatch(prePush, /# Generated by kit/); + assert.doesNotMatch(prePush, /npm audit --audit-level=high/); + }); + it("pushes missing deploy env values from declared secrets without echoing values", async () => { await writeFile(join(tempDir, ".kit.toml"), FIXTURE_DEPLOY_FIX, "utf-8"); const logPath = join(tempDir, "vercel.log"); diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts index f3f7ae5b..20aa3b92 100644 --- a/src/commands/hooks.ts +++ b/src/commands/hooks.ts @@ -82,7 +82,13 @@ export async function cmdHooks(): Promise { let allOk = true; for (const r of results) { - const icon = r.action === "failed" ? `${c.red}✗${c.reset}` : `${c.green}✓${c.reset}`; + const ok = r.action !== "failed" && (r.action !== "skipped" || r.satisfied === true); + const icon = + r.action === "failed" + ? `${c.red}✗${c.reset}` + : ok + ? `${c.green}✓${c.reset}` + : `${c.yellow}!${c.reset}`; const label = r.action === "installed" ? `${c.green}installed${c.reset}` @@ -92,7 +98,7 @@ export async function cmdHooks(): Promise { ? `${c.dim}skipped${c.reset}` : `${c.red}failed${c.reset}`; console.log(` ${icon} ${r.hookName} ${label} ${c.dim}${r.detail}${c.reset}`); - if (r.action === "failed") allOk = false; + if (!ok) allOk = false; } console.log(); diff --git a/src/fix.ts b/src/fix.ts index 7bc20d73..48ebe793 100644 --- a/src/fix.ts +++ b/src/fix.ts @@ -464,8 +464,27 @@ export async function cmdFix(cwd: string = process.cwd()): Promise { if (config.hooks && Object.keys(config.hooks).length > 0) { try { const hookResults = await installHooks(config.hooks, ".git", cwd); - const installed = hookResults.filter((r) => r.action === "installed"); - const updated = hookResults.filter((r) => r.action === "updated"); + const configuredHookNames = new Set( + Object.entries(config.hooks) + .filter(([, commands]) => commands && commands.length > 0) + .map(([name]) => name), + ); + const installed = hookResults.filter( + (r) => r.action === "installed" && configuredHookNames.has(r.hookName), + ); + const updated = hookResults.filter( + (r) => r.action === "updated" && configuredHookNames.has(r.hookName), + ); + const auxiliary = hookResults.filter( + (r) => + (r.action === "installed" || r.action === "updated") && + !configuredHookNames.has(r.hookName), + ); + const skipped = hookResults.filter( + (r) => r.action === "skipped" && configuredHookNames.has(r.hookName), + ); + const satisfiedSkipped = skipped.filter((r) => r.satisfied); + const blockedSkipped = skipped.filter((r) => !r.satisfied); const failed = hookResults.filter((r) => r.action === "failed"); if (installed.length > 0) { console.log( @@ -478,6 +497,30 @@ export async function cmdFix(cwd: string = process.cwd()): Promise { ` ${c.dim}↻ Updated ${updated.length} existing hook(s): ${updated.map((r) => r.hookName).join(", ")}${c.reset}`, ); } + if (auxiliary.length > 0) { + console.log( + ` ${c.dim}↻ Hook support installed/updated: ${auxiliary.map((r) => r.hookName).join(", ")}${c.reset}`, + ); + } + if (satisfiedSkipped.length > 0) { + console.log( + ` ${c.green}✓${c.reset} Externally managed hook(s) already satisfy config: ${satisfiedSkipped.map((r) => r.hookName).join(", ")}`, + ); + } + if (blockedSkipped.length > 0) { + for (const s of blockedSkipped) { + console.log(` ${c.yellow}!${c.reset} ${s.hookName}: ${s.detail}`); + manualActions.push({ + blocker: `${s.hookName} hook was left untouched`, + owner: "developer", + reason: "existing non-kit git hook", + steps: [s.detail, "Run `kit check --category hooks`."], + respondWith: `${s.hookName} hook includes the configured kit commands or is intentionally unmanaged`, + agentContinuesWith: "kit check --category hooks", + }); + manualCount++; + } + } if (failed.length > 0) { for (const f of failed) { console.log(` ${c.red}✗${c.reset} ${f.hookName}: ${f.detail}`); @@ -492,7 +535,13 @@ export async function cmdFix(cwd: string = process.cwd()): Promise { manualCount++; } } - if (installed.length === 0 && updated.length === 0 && failed.length === 0) { + if ( + installed.length === 0 && + updated.length === 0 && + auxiliary.length === 0 && + skipped.length === 0 && + failed.length === 0 + ) { console.log(`${c.dim}Hooks up to date${c.reset}`); } } catch (err: unknown) { diff --git a/src/hooks.test.ts b/src/hooks.test.ts index e89fa4f9..b7d5f1ad 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -256,6 +256,22 @@ describe("checkHooks", () => { assert.equal(results[0].upToDate, false); assert.ok(results[0].detail.includes("not managed by kit")); }); + + it("accepts a non-kit hook when it contains the configured commands", async () => { + const config: HooksConfig = { + "pre-commit": ["npm run lint"], + }; + + await mkdir(join(testGitDir, "hooks"), { recursive: true }); + await writeFile(join(testGitDir, "hooks", "pre-commit"), "#!/bin/sh\nnpm run lint\n", "utf-8"); + + const results = await checkHooks(config, testGitDir); + + assert.equal(results.length, 1); + assert.equal(results[0].installed, true); + assert.equal(results[0].upToDate, true); + assert.ok(results[0].detail.includes("externally managed")); + }); }); describe("uninstallHooks", () => { diff --git a/src/hooks.ts b/src/hooks.ts index c3187a87..9b21277c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -47,6 +47,12 @@ export interface HookInstallResult { hookName: string; action: "installed" | "updated" | "skipped" | "failed"; detail: string; + /** True when kit did not write the hook, but the existing hook already satisfies the config. */ + satisfied?: boolean; +} + +export function missingHookCommands(content: string, commands: string[]): string[] { + return commands.filter((cmd) => !content.includes(cmd)); } /** @@ -187,7 +193,7 @@ function sentinelWriterScript(): string { # means \`git commit --no-verify\` skipped the gate. __kit_git_dir=\${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} if [ -n "$__kit_git_dir" ]; then - date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/${HOOK_SENTINEL_REL}" 2>/dev/null || true + { date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/${HOOK_SENTINEL_REL}"; } 2>/dev/null || true fi`; } @@ -263,10 +269,20 @@ async function installHook( const { readFile } = await import("node:fs/promises"); const current = await readFile(hookPath, "utf-8").catch(() => ""); if (current && !current.includes("Generated by kit")) { + const missing = missingHookCommands(current, commands); + if (missing.length === 0) { + return { + hookName, + action: "skipped", + detail: "externally managed; configured commands already present", + satisfied: true, + }; + } return { hookName, action: "skipped", - detail: `existing non-kit ${hookName} at ${hookPath} — left untouched; add \`${commands.join(" && ")}\` to it manually or remove it, then re-run`, + detail: `existing non-kit ${hookName} at ${hookPath} — left untouched; add \`${missing.join(" && ")}\` to it manually or remove it, then re-run`, + satisfied: false, }; } }