diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cff5040..5636687 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node: [20.19.0, 22.12.0] + node: [22.12.0, 24] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1257f..668c9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to junto are documented in this file. The project follows Semantic Versioning. +## [Unreleased] + +### Changed + +- Require Node.js 22.12 or newer; Node.js 20 reached end of life on 2026-04-30. CI now tests Node.js 22.12 and 24, and bundles target Node.js 22. +- Update `@modelcontextprotocol/sdk` to 1.30.1. + +### Fixed + +- Bind command gate verdicts to source contents outside `staleIgnore` in Git repository roots, so edits that bypass the edit hooks (Bash, formatters, codegen) make them stale. +- Block case variants and Windows trailing-dot or short-name aliases of protected `.junto/` evidence paths in the guard hook. +- Persist rule obligations and phase transitions under the task lock, keeping concurrent staleness and approval updates; refuse `done` if a required gate was invalidated during the transition. +- Reject reserved Windows device names for review gates, as for command gates. + ## [0.5.0] - 2026-09-22 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 940b196..ddc8e82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ corepack pnpm --filter @junto/core add ``` - Do not install pnpm globally or change `"packageManager": "pnpm@10.17.1"`. -- Node.js 20.19+ or 22.12+ is required by the Vite dependency chain. +- Node.js 22.12+ is required (Node.js 20 reached end of life on 2026-04-30). - Add every new package project to the root `tsconfig.json` references. - pnpm explicitly permits the esbuild install script through `pnpm.onlyBuiltDependencies`. diff --git a/README.md b/README.md index 1acefab..f6475ff 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ claude plugin install junto@junto No `npx`, downloaded binary, or install script is required. -Requirements: Claude Code with plugin support and Node.js 20 or newer available on `PATH`. +Requirements: Claude Code with plugin support and Node.js 22.12 or newer available on `PATH`. Verify the installed version or update an existing installation: @@ -72,9 +72,12 @@ advisory and never replace quality-gate evidence. ## Evidence, not promises `junto__verify` runs test commands itself and records the result. A model cannot merely claim that tests passed. -When source files change, previous verdicts become stale and gates must run again. +When source files change, previous verdicts become stale and gates must run again. In a Git repository +root, command gate verdicts are also bound to the contents of files outside `staleIgnore`, so edits made +through Bash, formatters, or code generators are caught at the next transition as well. -The `guard.js` hook blocks evidence writes through Edit/Write/MultiEdit, but it **cannot block Bash**. +The `guard.js` hook blocks evidence writes through Edit/Write/MultiEdit, including case and Windows +path aliases of `.junto/`, but it **cannot block Bash**. It prevents accidents and shortcuts; it is not a security boundary against a malicious actor. ## OpenCodeReview gates and rules diff --git a/package.json b/package.json index 81550fb..c8f5c86 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "packageManager": "pnpm@10.17.1", "pnpm": { "onlyBuiltDependencies": ["esbuild"] }, - "engines": { "node": ">=20" }, + "engines": { "node": ">=22.12" }, "scripts": { "typecheck": "tsc -b", "test": "vitest run", @@ -14,7 +14,7 @@ "@junto/core": "workspace:*" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.30.1", "@types/node": "^22.0.0", "esbuild": "^0.24.0", "typescript": "^5.9.2", diff --git a/packages/core/src/gates.ts b/packages/core/src/gates.ts index 4d1be0a..485333d 100644 --- a/packages/core/src/gates.ts +++ b/packages/core/src/gates.ts @@ -21,6 +21,7 @@ export interface RunGateOptions { name: string spec: GateSpec runner: string + sourceFingerprint?: string } /** Take a byte-limited tail without splitting a UTF-8 character. */ @@ -30,7 +31,8 @@ function tail(text: string, maxBytes: number): string { return new TextDecoder("utf-8", { fatal: false }).decode(buf.subarray(buf.byteLength - maxBytes)) } -function validGateName(name: string): boolean { +/** Shared by command and review gates, which write the same verdict file names. */ +export function validGateName(name: string): boolean { return VALID_GATE_NAME.test(name) && name !== "." && name !== ".." @@ -136,6 +138,7 @@ export async function runGate(opts: RunGateOptions): Promise { outputBytes: Buffer.byteLength(outcome.output, "utf-8"), outputFile: `verdicts/${name}.log`, runner, + ...(opts.sourceFingerprint ? { sourceFingerprint: opts.sourceFingerprint } : {}), ...(outcome.reason ? { reason: outcome.reason } : {}), } diff --git a/packages/core/src/review-freshness.ts b/packages/core/src/review-freshness.ts index 5c4e251..5ac86ed 100644 --- a/packages/core/src/review-freshness.ts +++ b/packages/core/src/review-freshness.ts @@ -3,7 +3,8 @@ import { createHash } from "node:crypto" import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, realpathSync } from "node:fs" import { basename, isAbsolute, join, relative, resolve } from "node:path" import { taskDir } from "./paths.js" -import type { Config, Task } from "./schema.js" +import type { Config, GateSpec, Task } from "./schema.js" +import { shouldStale } from "./stale.js" const IGNORED_UNTRACKED = new Set(["node_modules", "dist", "build", ".temp", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"]) const digest = (value: string | Buffer): string => createHash("sha256").update(value).digest("hex") @@ -19,8 +20,13 @@ function fileDigest(path: string): string { } finally { closeSync(fd) } } -/** Bind review evidence to source and policy without storing source text or reading environment files. */ -export function captureReviewFingerprint(root: string, task: Task, config: Config): string { +interface SourceState { + head: string | null + index: Buffer + files: Array<[string, number | null, string]> +} + +function sourceState(root: string, include: (path: string) => boolean = () => true): SourceState { // Native resolution expands Windows 8.3 aliases used by runner temporary directories. const canonicalRoot = realpathSync.native(root) const git = (...args: string[]): Buffer => execFileSync("git", args, { @@ -38,7 +44,7 @@ export function captureReviewFingerprint(root: string, task: Task, config: Confi const files: Array<[string, number | null, string]> = [] for (const path of [...paths].sort()) { const parts = path.split("/") - if (parts[0] === ".junto" || basename(path) === ".env" || basename(path).startsWith(".env.")) continue + if (parts[0] === ".junto" || basename(path) === ".env" || basename(path).startsWith(".env.") || !include(path)) continue if (!tracked.has(path) && parts.some(part => IGNORED_UNTRACKED.has(part))) continue const full = resolve(canonicalRoot, path) const rel = relative(canonicalRoot, full) @@ -56,6 +62,12 @@ export function captureReviewFingerprint(root: string, task: Task, config: Confi files.push([path, stat.mode, fileDigest(full)]) } else throw new Error("Review fingerprints do not support source directories or submodules") } + return { head, index, files } +} + +/** Bind review evidence to source and policy without storing source text or reading environment files. */ +export function captureReviewFingerprint(root: string, task: Task, config: Config): string { + const { head, index, files } = sourceState(root) const context = ["brief.md", "plan.md", "review-background.md"].map(name => { const path = join(taskDir(root, task.id), name) return existsSync(path) ? digest(readFileSync(path)) : null @@ -63,3 +75,16 @@ export function captureReviewFingerprint(root: string, task: Task, config: Confi return digest(JSON.stringify({ version: 1, head, index: digest(index), files, base: task.baseCommit, title: task.title, context, config })) } + +/** + * Bind a command gate verdict to the source it ran against, so edits that bypass the edit hooks + * (Bash, formatters, codegen) still make it stale. Only file contents outside `staleIgnore` and the + * gate's own spec count: commits, staging and documentation edits keep the verdict fresh. + * Returns null where fingerprints are unsupported (no Git repository root, submodules); those + * projects keep relying on the edit hooks alone. + */ +export function captureSourceFingerprint(root: string, config: Config, spec: GateSpec): string | null { + let files: SourceState["files"] + try { ({ files } = sourceState(root, path => shouldStale(path, config.staleIgnore))) } catch { return null } + return digest(JSON.stringify({ version: 1, kind: "command-gate", files, spec })) +} diff --git a/packages/core/src/review.ts b/packages/core/src/review.ts index 9889367..36220cb 100644 --- a/packages/core/src/review.ts +++ b/packages/core/src/review.ts @@ -2,7 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { execa } from "execa" import { resolveExecutable } from "./exec.js" -import { OUTPUT_TAIL_BYTES } from "./gates.js" +import { OUTPUT_TAIL_BYTES, validGateName } from "./gates.js" import { taskDir } from "./paths.js" import { SCHEMA_VERSION, type GateState, type VerdictFile } from "./schema.js" import type { ReviewScope } from "./review-scope.js" @@ -489,8 +489,6 @@ export interface RunReviewGateOptions { export const DEFAULT_FAIL_ON: ReviewSeverity[] = ["critical", "high"] -const VALID_GATE_NAME = /^[A-Za-z0-9._-]+$/ - /** * Run a review provider as a gate. Only the provider's real result decides the state: * a missing reviewer is `skipped` (never a pass), a broken reviewer is `fail`, and findings at or @@ -498,8 +496,11 @@ const VALID_GATE_NAME = /^[A-Za-z0-9._-]+$/ */ export async function runReviewGate(opts: RunReviewGateOptions): Promise { const { root, taskId, name, provider, runner } = opts - if (!VALID_GATE_NAME.test(name) || name === "." || name === "..") { - throw new Error(`Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-".`) + if (!validGateName(name)) { + throw new Error( + `Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-", ` + + "and do not use a reserved Windows device name.", + ) } const failOn = opts.failOn ?? DEFAULT_FAIL_ON const startedAt = new Date().toISOString() diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 10bc04d..0469f98 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -173,6 +173,8 @@ export interface VerdictFile { reason?: string /** Present on source-bound reviews; legacy reviews must be rerun. */ reviewFingerprint?: string + /** Present on command gates run in a Git repository root; binds the verdict to source contents. */ + sourceFingerprint?: string } export function parseTask(raw: unknown): Task { diff --git a/packages/core/test/review.test.ts b/packages/core/test/review.test.ts index 3c71b25..69111ff 100644 --- a/packages/core/test/review.test.ts +++ b/packages/core/test/review.test.ts @@ -265,4 +265,9 @@ describe("runReviewGate", () => { await expect(runReviewGate({ root: tmpRoot, taskId: "t", name: "../x", provider: new MockReviewProvider(), context: {}, runner: "t" })) .rejects.toThrow(/Invalid gate name/) }) + + it.each(["nul", "CON", "com1.log"])("rejects reserved Windows device gate names like command gates do: %s", async (name) => { + await expect(runReviewGate({ root: tmpRoot, taskId: "t", name, provider: new MockReviewProvider(), context: {}, runner: "t" })) + .rejects.toThrow(/Invalid gate name/) + }) }) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index dfb9403..59fe10e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -5,7 +5,7 @@ "main": "./src/server.ts", "dependencies": { "@junto/core": "workspace:*", - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.30.1", "execa": "^9.6.1", "zod": "^3.23.8" } diff --git a/packages/mcp/src/tools/advance.ts b/packages/mcp/src/tools/advance.ts index 969597a..c105196 100644 --- a/packages/mcp/src/tools/advance.ts +++ b/packages/mcp/src/tools/advance.ts @@ -3,12 +3,13 @@ import { join } from "node:path" import { canEnter, captureReviewFingerprint, + captureSourceFingerprint, gateStateSchema, readActiveId, readConfig, readTask, taskDir, - writeTask, + updateTask, type Config, type GateState, type Phase, @@ -16,7 +17,7 @@ import { type TransitionContext, } from "@junto/core" import type { ToolContext } from "../context.js" -import { resolveTaskPolicy } from "./policy.js" +import { persistTaskPolicy, resolveTaskPolicy } from "./policy.js" /** Read disk facts at the I/O boundary so `canEnter` remains pure. */ export function buildTransitionContext(root: string, task: Task, config: Config): TransitionContext { @@ -37,6 +38,15 @@ export function buildTransitionContext(root: string, task: Task, config: Config) return null } } + if (verdict.sourceFingerprint !== undefined) { + // Catches edits the hooks cannot see; an unsupported or changed fingerprint fails closed. + const spec = config.gates[name] + if (spec === undefined || captureSourceFingerprint(root, config, spec) !== verdict.sourceFingerprint) { + const gate = task.gates[name] + if (gate) gate.stale = true + return null + } + } return gateStateSchema.parse(verdict.state) } catch { if (config.gates[name]?.type === "review") { @@ -69,16 +79,24 @@ export async function advanceTool(ctx: ToolContext, input: { to: Phase }): Promi const stored = readTask(ctx.root, id) const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config) // Persist new obligations even when blocked so the user approval hook sees them. - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task) + persistTaskPolicy(ctx.root, stored, task) const check = canEnter(task, input.to, { ...buildTransitionContext(ctx.root, task, config), unknownRuleGates: unknownGates }) if (!check.ok) throw new Error(`Cannot transition to "${input.to}". ${check.reason}`) + // Apply only the transition to the latest task so concurrent hook updates (staleness, + // approval) survive, and refuse if the task moved or a gate was invalidated after the check. const now = new Date().toISOString() - const previous = task.phases[task.phase] - if (previous !== undefined) task.phases[task.phase] = { ...previous, status: "done", at: now } - task.phases[input.to] = { ...(task.phases[input.to] ?? {}), status: "active", at: now } - task.phase = input.to - writeTask(ctx.root, task) + updateTask(ctx.root, id, current => { + const invalidated = Object.entries(current.gates).some(([name, gate]) => gate.required + && (gate.stale || (gate.invalidationVersion ?? 0) !== (task.gates[name]?.invalidationVersion ?? 0))) + if (current.phase !== task.phase || (input.to === "done" && invalidated)) { + throw new Error(`Cannot transition to "${input.to}". The task changed during the transition; retry.`) + } + const previous = current.phases[current.phase] + if (previous !== undefined) current.phases[current.phase] = { ...previous, status: "done", at: now } + current.phases[input.to] = { ...(current.phases[input.to] ?? {}), status: "active", at: now } + current.phase = input.to + }) const nudge = input.to === "panel" ? " Run /junto:panel to review the approved plan, then advance to build." diff --git a/packages/mcp/src/tools/plan.ts b/packages/mcp/src/tools/plan.ts index bf27685..e3c9f6b 100644 --- a/packages/mcp/src/tools/plan.ts +++ b/packages/mcp/src/tools/plan.ts @@ -2,11 +2,11 @@ import { mkdirSync, writeFileSync } from "node:fs" import { isAbsolute, join, resolve } from "node:path" import { OpenCodeReviewProvider, RuleMatcher, SkillResolver, buildPlan, - readActiveId, readConfig, readTask, resolveReviewScopes, resolveTaskChanges, taskDir, writeTask, + readActiveId, readConfig, readTask, resolveReviewScopes, resolveTaskChanges, taskDir, type DelegatePreview, type JuntoPlan, type ReviewScope, } from "@junto/core" import type { ToolContext } from "../context.js" -import { configRules, resolveTaskPolicy } from "./policy.js" +import { configRules, persistTaskPolicy, resolveTaskPolicy } from "./policy.js" export interface ResolvedPlan extends JuntoPlan { base: string | null @@ -31,7 +31,7 @@ export async function resolvePlan(ctx: ToolContext): Promise { const files = changes.filter(c => c.status !== "deleted").map(c => c.path) const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config, changes) - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task) + persistTaskPolicy(ctx.root, stored, task) const rules = configRules(config) const plan = buildPlan(task.title, files, new RuleMatcher(rules), { diff --git a/packages/mcp/src/tools/policy.ts b/packages/mcp/src/tools/policy.ts index a4bd49d..c367a94 100644 --- a/packages/mcp/src/tools/policy.ts +++ b/packages/mcp/src/tools/policy.ts @@ -1,4 +1,4 @@ -import { RuleMatcher, resolveTaskChanges, type ChangedFile, type Config, type Rule, type Task } from "@junto/core" +import { RuleMatcher, resolveTaskChanges, updateTask, type ChangedFile, type Config, type Rule, type Task } from "@junto/core" export function configRules(config: Config): Rule[] { return (config.rules ?? []).map(r => ({ @@ -30,3 +30,19 @@ export async function resolveTaskPolicy(root: string, task: Task, config: Config unknownGates, } } + +/** + * Persist resolved obligations under the task lock. They only ever add gates or raise flags, so + * merging them into the latest task keeps concurrent hook updates (staleness, approval). + */ +export function persistTaskPolicy(root: string, stored: Task, resolved: Task): void { + if (JSON.stringify(resolved) === JSON.stringify(stored)) return + updateTask(root, resolved.id, current => { + for (const [name, gate] of Object.entries(resolved.gates)) { + const existing = current.gates[name] + if (existing === undefined) current.gates[name] = { ...gate } + else existing.required ||= gate.required + } + if (resolved.ruleApprovalRequired) current.ruleApprovalRequired = true + }) +} diff --git a/packages/mcp/src/tools/verify.ts b/packages/mcp/src/tools/verify.ts index eb003a2..149759b 100644 --- a/packages/mcp/src/tools/verify.ts +++ b/packages/mcp/src/tools/verify.ts @@ -1,10 +1,10 @@ import { - captureReviewFingerprint, CliReviewProvider, OpenCodeReviewProvider, ScopedReviewProvider, readActiveId, readConfig, readTask, resolveReviewScopes, runGate, runReviewGate, updateTask, - writeReviewBackground, writeTask, + captureReviewFingerprint, captureSourceFingerprint, CliReviewProvider, OpenCodeReviewProvider, ScopedReviewProvider, readActiveId, readConfig, readTask, resolveReviewScopes, runGate, runReviewGate, updateTask, + writeReviewBackground, } from "@junto/core" import type { Config, GateSpec, Task, VerdictFile } from "@junto/core" import type { ToolContext } from "../context.js" -import { resolveTaskPolicy } from "./policy.js" +import { persistTaskPolicy, resolveTaskPolicy } from "./policy.js" const FAIL_STREAK_HINT_AT = 3 @@ -65,7 +65,7 @@ export async function verifyTool(ctx: ToolContext, input: { gates?: string[] }): const config = readConfig(ctx.root) const stored = readTask(ctx.root, id) const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config) - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task) + persistTaskPolicy(ctx.root, stored, task) if (unknownGates.length) throw new Error(`Rules reference unconfigured gates: ${unknownGates.join(", ")}. Fix .junto/config.json.`) const names = input.gates ?? Object.keys(task.gates) @@ -82,9 +82,10 @@ export async function verifyTool(ctx: ToolContext, input: { gates?: string[] }): const started = updateTask(ctx.root, id, current => { const gate = current.gates[name]; if (gate) gate.stale = true }) status.invalidationVersion = started.gates[name]?.invalidationVersion ?? 0 + const sourceFingerprint = spec.type === "review" ? null : captureSourceFingerprint(ctx.root, config, spec) const verdict = spec.type === "review" ? await runReview(ctx, task, name, spec, config) - : await runGate({ root: ctx.root, taskId: id, name, spec, runner: ctx.runner }) + : await runGate({ root: ctx.root, taskId: id, name, spec, runner: ctx.runner, ...(sourceFingerprint ? { sourceFingerprint } : {}) }) // Build the path from the validated name rather than coupling to outputFile formatting. status.verdict = `verdicts/${name}.json` @@ -94,13 +95,19 @@ export async function verifyTool(ctx: ToolContext, input: { gates?: string[] }): else if (verdict.state === "fail") status.failStreak = status.failStreak + 1 sections.push(render(verdict, status.failStreak)) + // Hash outside the task lock: edit hooks wait only briefly for it. Source edited while the gate + // ran leaves a result that may describe neither version; advance rechecks the fingerprint later. + const latest = readConfig(ctx.root) + const latestSpec = latest.gates[name] + const changedDuringRun = (verdict.reviewFingerprint !== undefined + && captureReviewFingerprint(ctx.root, readTask(ctx.root, id), latest) !== verdict.reviewFingerprint) + || (verdict.sourceFingerprint !== undefined && (latestSpec === undefined + || captureSourceFingerprint(ctx.root, latest, latestSpec) !== verdict.sourceFingerprint)) + // Persist after every gate so a later failure cannot discard completed evidence. updateTask(ctx.root, id, current => { const version = current.gates[name]?.invalidationVersion ?? 0 - current.gates[name] = { ...status, invalidationVersion: version, stale: version !== status.invalidationVersion } - if (verdict.reviewFingerprint) { - current.gates[name].stale ||= captureReviewFingerprint(ctx.root, current, readConfig(ctx.root)) !== verdict.reviewFingerprint - } + current.gates[name] = { ...status, invalidationVersion: version, stale: version !== status.invalidationVersion || changedDuringRun } }) } diff --git a/packages/mcp/test/advance-tool.test.ts b/packages/mcp/test/advance-tool.test.ts index 9cfd05e..406c1a4 100644 --- a/packages/mcp/test/advance-tool.test.ts +++ b/packages/mcp/test/advance-tool.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -5,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest" import { readActiveId, readTask, taskDir, writeTask } from "@junto/core" import { advanceTool } from "../src/tools/advance.js" import { taskTool } from "../src/tools/task.js" +import { persistTaskPolicy } from "../src/tools/policy.js" import { verifyTool } from "../src/tools/verify.js" let root: string @@ -136,3 +138,62 @@ describe("advanceTool", () => { expect(readTask(root, id).phase).toBe("verify") }) }) + +describe("advanceTool with source-bound command gates", () => { + const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" }) + + beforeEach(() => { + git("init", "-q") + git("config", "user.email", "junto@example.test") + git("config", "user.name", "Junto Test") + writeFileSync(join(root, "app.js"), "export const value = 1\n") + writeFileSync(join(root, "README.md"), "# App\n") + git("add", "app.js", "README.md") + git("commit", "-q", "-m", "init") + }) + + async function passTests(): Promise { + await taskTool(ctx(), { action: "start", title: "X", size: "small" }) + const id = activeId() + await advanceTool(ctx(), { to: "verify" }) + await verifyTool(ctx(), {}) + return id + } + + it("blocks done when source changed without an edit hook (for example through Bash)", async () => { + await passTests() + writeFileSync(join(root, "app.js"), "export const value = 2\n") + await expect(advanceTool(ctx(), { to: "done" })).rejects.toThrow(/tests \(stale evidence/) + }) + + it("ignores files covered by staleIgnore and commits of unchanged content", async () => { + const id = await passTests() + writeFileSync(join(root, "README.md"), "# App\n\nMore docs.\n") + git("add", "README.md") + git("commit", "-q", "-m", "docs") + await advanceTool(ctx(), { to: "done" }) + expect(readTask(root, id).phase).toBe("done") + }) + + it("keeps a user approval recorded while policy obligations are persisted", async () => { + await taskTool(ctx(), { action: "start", title: "X", size: "small" }) + const id = activeId() + const stored = readTask(root, id) + const approved = readTask(root, id) + approved.phases.build = { ...(approved.phases.build ?? { status: "active" }), approvedBy: "user" } + writeTask(root, approved) + // Policy resolved from the older snapshot must merge, not overwrite the approval. + persistTaskPolicy(root, stored, { ...stored, ruleApprovalRequired: true }) + const after = readTask(root, id) + expect(after.phases.build?.approvedBy).toBe("user") + expect(after.ruleApprovalRequired).toBe(true) + }) + + it("stays done-able after reverting a change made after the gate ran", async () => { + const id = await passTests() + writeFileSync(join(root, "app.js"), "export const value = 2\n") + writeFileSync(join(root, "app.js"), "export const value = 1\n") + await advanceTool(ctx(), { to: "done" }) + expect(readTask(root, id).phase).toBe("done") + }) +}) diff --git a/plugin/hooks/guard.js b/plugin/hooks/guard.js index 560117c..7035853 100644 --- a/plugin/hooks/guard.js +++ b/plugin/hooks/guard.js @@ -6,7 +6,8 @@ var __export = (target, all) => { }; // src-hooks/guard.ts -import { isAbsolute, relative, resolve } from "node:path"; +import { existsSync as existsSync3, realpathSync } from "node:fs"; +import { basename, dirname as dirname2, isAbsolute, join as join3, relative, resolve } from "node:path"; // node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js var external_exports = {}; @@ -4377,7 +4378,19 @@ async function runHook(fn) { // src-hooks/guard.ts function toRegExp(glob) { const body = glob.split("/").map((segment) => segment === "**" ? ".*" : segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("/"); - return new RegExp(`^${body}$`); + return new RegExp(`^${body}$`, "i"); +} +function canonicalPath(path) { + const tail = []; + let existing = path; + while (!existsSync3(existing)) { + const parent = dirname2(existing); + if (parent === existing) return path; + tail.unshift(basename(existing)); + existing = parent; + } + const segments = process.platform === "win32" ? tail.map((segment) => segment.replace(/[. ]+$/, "")) : tail; + return join3(realpathSync.native(existing), ...segments); } function isProtected(relPath) { const normalized = relPath.replace(/\\/g, "/").replace(/^\.\//, ""); @@ -4399,7 +4412,7 @@ function handleGuard(input) { const root = findProjectRoot(cwd); if (root === null) return ""; const absolutePath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath); - const rel = relative(root, absolutePath).replace(/\\/g, "/"); + const rel = relative(realpathSync.native(root), canonicalPath(absolutePath)).replace(/\\/g, "/"); if (rel === ".." || rel.startsWith("../")) return ""; if (input.hook_event_name === "PreToolUse") { if (!isProtected(rel)) return ""; diff --git a/plugin/mcp/server.js b/plugin/mcp/server.js index 6309d58..7420824 100644 --- a/plugin/mcp/server.js +++ b/plugin/mcp/server.js @@ -3105,12 +3105,13 @@ var require_data = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/utils.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js var require_utils = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/utils.js"(exports, module) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js"(exports, module) { "use strict"; var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); + var isPort = RegExp.prototype.test.bind(/^\d*$/u); var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu); var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu); var isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u); @@ -3576,8 +3577,12 @@ var require_utils = __commonJS({ uriTokens.push(host); } if (typeof component.port === "number" || typeof component.port === "string") { + const port = String(component.port); + if (!isPort(port)) { + throw new TypeError("URI port is malformed."); + } uriTokens.push(":"); - uriTokens.push(String(component.port)); + uriTokens.push(port); } return uriTokens.length ? uriTokens.join("") : void 0; } @@ -3602,9 +3607,9 @@ var require_utils = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/schemes.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js var require_schemes = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/schemes.js"(exports, module) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js"(exports, module) { "use strict"; var { isUUID } = require_utils(); var URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu; @@ -3813,9 +3818,9 @@ var require_schemes = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/index.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js var require_fast_uri = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/index.js"(exports, module) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js"(exports, module) { "use strict"; var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils(); var { SCHEMES, getSchemeHandler } = require_schemes(); @@ -4020,12 +4025,15 @@ var require_fast_uri = __commonJS({ } return false; } + function isIPLiteral(host) { + return host[0] === "[" && host[host.length - 1] === "]"; + } function hasMalformedComponentPercentEncoding(matches) { const host = matches[4]; - return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !(host[0] === "[" && host[host.length - 1] === "]") && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]); + return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]); } function canonicalizeHost(parsed, options, schemeHandler, isIP) { - if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && parsed.host[0] !== "[" && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { + if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { try { parsed.host = new URL("http://" + parsed.host).hostname; } catch (e) { @@ -4112,10 +4120,11 @@ var require_fast_uri = __commonJS({ if (parsed.host) { const ipv4result = isIPv4(parsed.host); if (ipv4result === false) { - const bracketedIPLiteral = parsed.host[0] === "[" && parsed.host[parsed.host.length - 1] === "]"; + const bracketedIPLiteral = isIPLiteral(parsed.host); + const hasIPLiteralBracket = parsed.host.indexOf("[") !== -1 || parsed.host.indexOf("]") !== -1; const ipv6result = normalizeIPv6(parsed.host); isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true; - malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true; + malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true); parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase(); if (malformedIPLiteral) { parsed.error = parsed.error || "URI host is malformed."; @@ -4138,14 +4147,17 @@ var require_fast_uri = __commonJS({ parsed.error = parsed.error || "URI is not a " + options.reference + " reference."; } const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme); - malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP); - if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { - if (uri.indexOf("%") !== -1) { - if (parsed.host !== void 0 && !malformedIPLiteral) { - const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true); - parsed.host = reescapeHostDelimiters(host, isIP); - } + if (!malformedIPLiteral) { + malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP); + } + if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) { + let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true); + if (!isIP) { + host = normalizePercentEncoding(host.toLowerCase()); } + parsed.host = reescapeHostDelimiters(host, isIP); + } + if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { if (parsed.path) { parsed.path = normalizePathEncoding(parsed.path); } @@ -7686,7 +7698,7 @@ var require_cross_spawn = __commonJS({ } }); -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js import process2 from "node:process"; // node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/core.js @@ -11421,7 +11433,7 @@ function preprocess(fn, schema) { // node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/classic/external.js config(en_default()); -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/types.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/types.js var LATEST_PROTOCOL_VERSION = "2025-11-25"; var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]; var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; @@ -12940,7 +12952,7 @@ var UrlElicitationRequiredError = class extends McpError { } }; -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; var ReadBuffer = class { constructor(options) { @@ -12977,7 +12989,7 @@ function serializeMessage(message) { return JSON.stringify(message) + "\n"; } -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js var StdioServerTransport = class { constructor(_stdin = process2.stdin, _stdout = process2.stdout, options) { this._stdin = _stdin; @@ -17085,7 +17097,7 @@ var coerce = { }; var NEVER2 = INVALID; -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js function isZ4Schema(s) { const schema = s; return !!schema._zod; @@ -17148,7 +17160,7 @@ function getLiteralValue(schema) { return void 0; } -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js function isTerminal(status) { return status === "completed" || status === "failed" || status === "cancelled"; } @@ -17159,7 +17171,7 @@ var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use") // node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/parsers/string.js var ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789"); -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js function getMethodLiteral(schema) { const shape = getObjectShape(schema); const methodSchema = shape?.method; @@ -17180,7 +17192,7 @@ function parseWithCompat(schema, data) { return result.data; } -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js var DEFAULT_REQUEST_TIMEOUT_MSEC = 6e4; var Protocol = class { constructor(_options) { @@ -18134,7 +18146,7 @@ function mergeCapabilities(base, additional) { return result; } -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js var import_ajv = __toESM(require_ajv(), 1); var import_ajv_formats = __toESM(require_dist(), 1); function createDefaultAjvInstance() { @@ -18202,7 +18214,7 @@ var AjvJsonSchemaValidator = class { } }; -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js var ExperimentalServerTasks = class { constructor(_server) { this._server = _server; @@ -18415,7 +18427,7 @@ var ExperimentalServerTasks = class { } }; -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js function assertToolsCallTaskCapability(requests, method, entityName) { if (!requests) { throw new Error(`${entityName} does not support task creation (required for ${method})`); @@ -18450,7 +18462,7 @@ function assertClientRequestTaskCapability(requests, method, entityName) { } } -// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js +// node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js var Server = class extends Protocol { /** * Initializes this server with the given name and version information. @@ -26094,6 +26106,7 @@ async function runGate(opts) { outputBytes: Buffer.byteLength(outcome.output, "utf-8"), outputFile: `verdicts/${name}.log`, runner, + ...opts.sourceFingerprint ? { sourceFingerprint: opts.sourceFingerprint } : {}, ...outcome.reason ? { reason: outcome.reason } : {} }; writeFileSync3(join4(dir, `${name}.json`), `${JSON.stringify(verdict, null, 2)} @@ -27120,11 +27133,12 @@ var OpenCodeReviewProvider = class { } }; var DEFAULT_FAIL_ON = ["critical", "high"]; -var VALID_GATE_NAME2 = /^[A-Za-z0-9._-]+$/; async function runReviewGate(opts) { const { root, taskId, name, provider, runner } = opts; - if (!VALID_GATE_NAME2.test(name) || name === "." || name === "..") { - throw new Error(`Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-".`); + if (!validGateName(name)) { + throw new Error( + `Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-", and do not use a reserved Windows device name.` + ); } const failOn = opts.failOn ?? DEFAULT_FAIL_ON; const startedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -27410,7 +27424,7 @@ function fileDigest(path6) { closeSync2(fd); } } -function captureReviewFingerprint(root, task, config2) { +function sourceState(root, include = () => true) { const canonicalRoot = realpathSync2.native(root); const git = (...args) => execFileSync("git", args, { cwd: root, @@ -27433,7 +27447,7 @@ function captureReviewFingerprint(root, task, config2) { const files = []; for (const path6 of [...paths].sort()) { const parts = path6.split("/"); - if (parts[0] === ".junto" || basename2(path6) === ".env" || basename2(path6).startsWith(".env.")) continue; + if (parts[0] === ".junto" || basename2(path6) === ".env" || basename2(path6).startsWith(".env.") || !include(path6)) continue; if (!tracked.has(path6) && parts.some((part) => IGNORED_UNTRACKED.has(part))) continue; const full = resolve3(canonicalRoot, path6); const rel = relative2(canonicalRoot, full); @@ -27453,6 +27467,10 @@ function captureReviewFingerprint(root, task, config2) { files.push([path6, stat.mode, fileDigest(full)]); } else throw new Error("Review fingerprints do not support source directories or submodules"); } + return { head, index, files }; +} +function captureReviewFingerprint(root, task, config2) { + const { head, index, files } = sourceState(root); const context = ["brief.md", "plan.md", "review-background.md"].map((name) => { const path6 = join10(taskDir(root, task.id), name); return existsSync9(path6) ? digest(readFileSync9(path6)) : null; @@ -27468,6 +27486,15 @@ function captureReviewFingerprint(root, task, config2) { config: config2 })); } +function captureSourceFingerprint(root, config2, spec) { + let files; + try { + ({ files } = sourceState(root, (path6) => shouldStale(path6, config2.staleIgnore))); + } catch { + return null; + } + return digest(JSON.stringify({ version: 1, kind: "command-gate", files, spec })); +} // packages/mcp/src/version.ts var VERSION = "0.5.0"; @@ -27518,6 +27545,17 @@ async function resolveTaskPolicy(root, task, config2, changes) { unknownGates }; } +function persistTaskPolicy(root, stored, resolved) { + if (JSON.stringify(resolved) === JSON.stringify(stored)) return; + updateTask(root, resolved.id, (current) => { + for (const [name, gate] of Object.entries(resolved.gates)) { + const existing = current.gates[name]; + if (existing === void 0) current.gates[name] = { ...gate }; + else existing.required ||= gate.required; + } + if (resolved.ruleApprovalRequired) current.ruleApprovalRequired = true; + }); +} // packages/mcp/src/tools/advance.ts function buildTransitionContext(root, task, config2) { @@ -27537,6 +27575,14 @@ function buildTransitionContext(root, task, config2) { return null; } } + if (verdict.sourceFingerprint !== void 0) { + const spec = config2.gates[name]; + if (spec === void 0 || captureSourceFingerprint(root, config2, spec) !== verdict.sourceFingerprint) { + const gate = task.gates[name]; + if (gate) gate.stale = true; + return null; + } + } return gateStateSchema.parse(verdict.state); } catch { if (config2.gates[name]?.type === "review") { @@ -27564,15 +27610,20 @@ async function advanceTool(ctx, input) { const config2 = readConfig(ctx.root); const stored = readTask(ctx.root, id); const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config2); - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task); + persistTaskPolicy(ctx.root, stored, task); const check2 = canEnter(task, input.to, { ...buildTransitionContext(ctx.root, task, config2), unknownRuleGates: unknownGates }); if (!check2.ok) throw new Error(`Cannot transition to "${input.to}". ${check2.reason}`); const now = (/* @__PURE__ */ new Date()).toISOString(); - const previous = task.phases[task.phase]; - if (previous !== void 0) task.phases[task.phase] = { ...previous, status: "done", at: now }; - task.phases[input.to] = { ...task.phases[input.to] ?? {}, status: "active", at: now }; - task.phase = input.to; - writeTask(ctx.root, task); + updateTask(ctx.root, id, (current) => { + const invalidated = Object.entries(current.gates).some(([name, gate]) => gate.required && (gate.stale || (gate.invalidationVersion ?? 0) !== (task.gates[name]?.invalidationVersion ?? 0))); + if (current.phase !== task.phase || input.to === "done" && invalidated) { + throw new Error(`Cannot transition to "${input.to}". The task changed during the transition; retry.`); + } + const previous = current.phases[current.phase]; + if (previous !== void 0) current.phases[current.phase] = { ...previous, status: "done", at: now }; + current.phases[input.to] = { ...current.phases[input.to] ?? {}, status: "active", at: now }; + current.phase = input.to; + }); const nudge = input.to === "panel" ? " Run /junto:panel to review the approved plan, then advance to build." : input.to === "review" ? " Run /junto:panel to review the implementation, then advance to verify." : ""; return `Task "${id}" transitioned to phase ${input.to}.${nudge}`; } @@ -27782,7 +27833,7 @@ async function resolvePlan(ctx) { const changes = await resolveTaskChanges(ctx.root, stored.baseCommit); const files = changes.filter((c3) => c3.status !== "deleted").map((c3) => c3.path); const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config2, changes); - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task); + persistTaskPolicy(ctx.root, stored, task); const rules = configRules(config2); const plan = buildPlan(task.title, files, new RuleMatcher(rules), { id, @@ -28104,7 +28155,7 @@ async function verifyTool(ctx, input) { const config2 = readConfig(ctx.root); const stored = readTask(ctx.root, id); const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config2); - if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task); + persistTaskPolicy(ctx.root, stored, task); if (unknownGates.length) throw new Error(`Rules reference unconfigured gates: ${unknownGates.join(", ")}. Fix .junto/config.json.`); const names = input.gates ?? Object.keys(task.gates); const sections = []; @@ -28120,18 +28171,19 @@ async function verifyTool(ctx, input) { if (gate) gate.stale = true; }); status.invalidationVersion = started.gates[name]?.invalidationVersion ?? 0; - const verdict = spec.type === "review" ? await runReview(ctx, task, name, spec, config2) : await runGate({ root: ctx.root, taskId: id, name, spec, runner: ctx.runner }); + const sourceFingerprint = spec.type === "review" ? null : captureSourceFingerprint(ctx.root, config2, spec); + const verdict = spec.type === "review" ? await runReview(ctx, task, name, spec, config2) : await runGate({ root: ctx.root, taskId: id, name, spec, runner: ctx.runner, ...sourceFingerprint ? { sourceFingerprint } : {} }); status.verdict = `verdicts/${name}.json`; status.stale = false; if (verdict.state === "pass") status.failStreak = 0; else if (verdict.state === "fail") status.failStreak = status.failStreak + 1; sections.push(render(verdict, status.failStreak)); + const latest = readConfig(ctx.root); + const latestSpec = latest.gates[name]; + const changedDuringRun = verdict.reviewFingerprint !== void 0 && captureReviewFingerprint(ctx.root, readTask(ctx.root, id), latest) !== verdict.reviewFingerprint || verdict.sourceFingerprint !== void 0 && (latestSpec === void 0 || captureSourceFingerprint(ctx.root, latest, latestSpec) !== verdict.sourceFingerprint); updateTask(ctx.root, id, (current) => { const version2 = current.gates[name]?.invalidationVersion ?? 0; - current.gates[name] = { ...status, invalidationVersion: version2, stale: version2 !== status.invalidationVersion }; - if (verdict.reviewFingerprint) { - current.gates[name].stale ||= captureReviewFingerprint(ctx.root, current, readConfig(ctx.root)) !== verdict.reviewFingerprint; - } + current.gates[name] = { ...status, invalidationVersion: version2, stale: version2 !== status.invalidationVersion || changedDuringRun }; }); } return sections.join("\n\n"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20233c1..5e073f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: version: link:packages/core devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.30.0(zod@3.25.76) + specifier: ^1.30.1 + version: 1.30.1(zod@3.25.76) '@types/node': specifier: ^22.0.0 version: 22.20.1 @@ -43,8 +43,8 @@ importers: specifier: workspace:* version: link:../core '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.30.0(zod@3.25.76) + specifier: ^1.30.1 + version: 1.30.1(zod@3.25.76) execa: specifier: ^9.6.1 version: 9.6.1 @@ -369,8 +369,8 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.30.1': + resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -725,8 +725,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -785,8 +785,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + hono@4.13.9: + resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -804,8 +804,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.7.0: - resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -830,8 +830,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -944,8 +944,8 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} qs@6.16.0: @@ -1342,15 +1342,15 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@2.1.1(hono@4.13.5)': + '@hono/node-server@2.1.1(hono@4.13.9)': dependencies: - hono: 4.13.5 + hono: 4.13.9 '@jridgewell/sourcemap-codec@1.6.0': {} - '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.30.1(zod@3.25.76)': dependencies: - '@hono/node-server': 2.1.1(hono@4.13.5) + '@hono/node-server': 2.1.1(hono@4.13.9) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1360,8 +1360,8 @@ snapshots: eventsource-parser: 3.1.1 express: 5.2.1 express-rate-limit: 8.7.0(express@5.2.1) - hono: 4.13.5 - jose: 6.2.10 + hono: 4.13.9 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -1519,7 +1519,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1704,7 +1704,7 @@ snapshots: dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.7.0 + ip-address: 10.7.2 transitivePeerDependencies: - supports-color @@ -1729,7 +1729,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 @@ -1743,7 +1743,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.6: {} + fast-uri@3.1.8: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -1804,7 +1804,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.5: {} + hono@4.13.9: {} http-errors@2.0.1: dependencies: @@ -1822,7 +1822,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.7.0: {} + ip-address@10.7.2: {} ipaddr.js@1.9.1: {} @@ -1836,7 +1836,7 @@ snapshots: isexe@2.0.0: {} - jose@6.2.10: {} + jose@6.2.12: {} js-tokens@9.0.1: {} @@ -1917,7 +1917,7 @@ snapshots: dependencies: parse-ms: 4.0.0 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 diff --git a/scripts/build.mjs b/scripts/build.mjs index 7637da9..df3eba5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -4,7 +4,7 @@ import { build } from "esbuild" const shared = { bundle: true, platform: "node", - target: "node20", + target: "node22", format: "esm", banner: { js: "import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" }, } diff --git a/src-hooks/guard.ts b/src-hooks/guard.ts index f507990..f209447 100644 --- a/src-hooks/guard.ts +++ b/src-hooks/guard.ts @@ -1,4 +1,5 @@ -import { isAbsolute, relative, resolve } from "node:path" +import { existsSync, realpathSync } from "node:fs" +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { findProjectRoot, PROTECTED_GLOBS, @@ -23,7 +24,25 @@ function toRegExp(glob: string): RegExp { ? ".*" : segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")) .join("/") - return new RegExp(`^${body}$`) + // Case-insensitive: Windows and macOS resolve other spellings to the same evidence files. + return new RegExp(`^${body}$`, "i") +} + +/** + * Resolve the path the file system will actually write. The existing prefix is canonicalized + * (case, 8.3 short names, symlinks); Windows also drops trailing dots and spaces from new segments. + */ +function canonicalPath(path: string): string { + const tail: string[] = [] + let existing = path + while (!existsSync(existing)) { + const parent = dirname(existing) + if (parent === existing) return path + tail.unshift(basename(existing)) + existing = parent + } + const segments = process.platform === "win32" ? tail.map(segment => segment.replace(/[. ]+$/, "")) : tail + return join(realpathSync.native(existing), ...segments) } export function isProtected(relPath: string): boolean { @@ -50,7 +69,7 @@ export function handleGuard(input: HookInput): string { if (root === null) return "" const absolutePath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath) - const rel = relative(root, absolutePath).replace(/\\/g, "/") + const rel = relative(realpathSync.native(root), canonicalPath(absolutePath)).replace(/\\/g, "/") if (rel === ".." || rel.startsWith("../")) return "" if (input.hook_event_name === "PreToolUse") { diff --git a/test/hooks/guard.test.ts b/test/hooks/guard.test.ts index 4e052fe..2fcb58a 100644 --- a/test/hooks/guard.test.ts +++ b/test/hooks/guard.test.ts @@ -76,6 +76,20 @@ describe("PreToolUse", () => { }) it("allows ordinary source files", () => expect(pre(join(root, "src", "app.ts"))).toBe("")) + + // Windows and macOS file systems resolve these spellings to the protected evidence files. + it.each([ + ".JUNTO/tasks/t/verdicts/tests.json", + ".junto/tasks/t/Verdicts/tests.json", + ".junto/TASKS/t/TASK.JSON", + ".Junto/Active", + ])("rejects case variants of protected paths: %s", (file) => { + expect(JSON.parse(pre(file)).hookSpecificOutput.permissionDecision).toBe("deny") + }) + + it.runIf(process.platform === "win32")("rejects Windows trailing-dot aliases of protected paths", () => { + expect(JSON.parse(pre(".junto./tasks/t/verdicts./tests.json")).hookSpecificOutput.permissionDecision).toBe("deny") + }) }) describe("PostToolUse", () => {