From 093b30789504cb7c4c4dc24fada3ed4e2a537436 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sun, 26 Jul 2026 02:23:59 -0300 Subject: [PATCH 1/2] feat:fencier-v0.1-release-candidate --- .github/workflows/ci.yml | 3 + README.md | 30 +++++--- package.json | 8 +- packages/cli/package.json | 20 ++++- packages/cli/src/cli.test.ts | 59 ++++++++++++++- packages/cli/src/cli.ts | 139 +++++++++++++++++++++++------------ packages/cli/src/output.ts | 11 +++ scripts/release-check.mjs | 107 +++++++++++++++++++++++++++ 8 files changed, 319 insertions(+), 58 deletions(-) create mode 100644 scripts/release-check.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb34d18..ada75f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - name: Build run: pnpm build + + - name: Validate installed CLI package + run: pnpm release:check diff --git a/README.md b/README.md index c88c46c..23fdb7c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Fencier gives AI-assisted development a deterministic operating boundary. Before Fencier does not replace Codex, code review, or security tooling. It makes the contract around an agent's work explicit, inspectable, and difficult to ignore. > [!IMPORTANT] -> Fencier is currently a development preview and is not published to npm. Use the local development workflow below. +> Fencier v0.1 is a release candidate. The CLI is validated as an installed package, but it is not published to npm yet. Use the local package workflow below until registry ownership is finalized. ## See it in action @@ -77,6 +77,7 @@ git clone https://github.com/DerMayer1/Fencier.git cd Fencier pnpm install pnpm run ci +pnpm release:check cd packages/cli npm link @@ -133,6 +134,12 @@ fencier verify --staged fencier verify --base origin/main ``` +Use `--json` for machine-readable output. Raw added lines are omitted so possible secret values cannot be exposed through the JSON result: + +```bash +fencier verify --json --no-audit +``` + ## Policy as code Every governed repository owns its boundary in `fencier.yaml`: @@ -264,14 +271,14 @@ This split keeps policy decisions unit-testable and makes every report traceable | Command | Purpose | |---|---| | `fencier init --codex` | Initialize policy, audit workspace, and the Codex adapter | -| `fencier doctor` | Check that the CLI is wired correctly | +| `fencier doctor` | Check the installed CLI version, Node.js requirement, and Git runtime | | `fencier codex prepare` | Check policy, adapter, local skills, and latest audit readiness | | `fencier codex brief` | Print deterministic repository context for Codex | | `fencier codex runbook ` | Compose a complete task runbook | | `fencier codex prompt list\|show` | Inspect versioned prompt templates | | `fencier codex checklist list\|show` | Inspect task checklists | | `fencier codex skill list\|show\|install` | Inspect and install repository-local skills | -| `fencier verify` | Evaluate the current diff and write configured audits | +| `fencier verify` | Evaluate the current diff and write configured audits; supports safe `--json` output | | `fencier check` | Compatibility alias for `verify` | | `fencier audit list` | List local audit reports | | `fencier audit show latest` | Print the latest Markdown audit | @@ -308,6 +315,7 @@ These boundaries are deliberate. Fencier stays useful by remaining small, determ pnpm install pnpm run ci pnpm run pack:cli +pnpm release:check ``` Run the built CLI without linking it globally: @@ -318,11 +326,11 @@ node packages/cli/dist/index.js codex prepare node packages/cli/dist/index.js verify --no-audit ``` -The repository currently contains 45 tests across the policy engine, Git collection, initialization, audits, adapters, skills, runbooks, and CLI behavior. +The test suite covers the policy engine, Git collection, initialization, audits, adapters, skills, runbooks, CLI behavior, and secret-safe machine output. `pnpm release:check` additionally packs the CLI, installs it into a temporary Git repository, and exercises doctor, initialization, skills, passing verification, blocking verification, and secret masking end to end. ## Project status -Implemented today: +The v0.1 local product contract is implemented: - deterministic local diff verification; - Markdown and JSON audit reports; @@ -331,14 +339,16 @@ Implemented today: - implementation, bugfix, review, refactor, security, and audit-fix runbooks; - compatibility adapters for Claude Code, Cursor, and GitHub Copilot; - idempotent repository initialization; +- real runtime diagnostics for Node.js and Git; +- secret-safe JSON verification output; +- install-from-tarball end-to-end release validation; - local CLI packaging validation and GitHub Actions CI. -Not implemented yet: +External release gate: + +- publish `@fencier/cli` after npm scope ownership and release credentials are confirmed. -- npm publication; -- global Codex skill installation; -- policy rule plugins; -- a richer fixture-based benchmark suite. +Possible post-v0.1 extensions, not required by the current product contract, include global Codex skill installation, policy rule plugins, and a larger benchmark corpus. ## Documentation diff --git a/package.json b/package.json index 1f6f843..43be8b5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,21 @@ { "name": "fencier-monorepo", - "version": "0.0.0", + "version": "0.1.0", "private": true, + "description": "Local-first policy boundary for AI coding agents.", + "license": "MIT", "type": "module", "packageManager": "pnpm@11.7.0", + "engines": { + "node": ">=22" + }, "scripts": { "build": "pnpm -r build", "ci": "pnpm lint && pnpm typecheck && pnpm test && pnpm build", "format": "biome format --write .", "lint": "biome check .", "pack:cli": "pnpm --filter @fencier/cli pack", + "release:check": "node scripts/release-check.mjs", "test": "vitest run", "typecheck": "pnpm -r typecheck" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index f892cd1..bc24471 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,29 @@ { "name": "@fencier/cli", - "version": "0.0.0", + "version": "0.1.0", + "description": "Local-first boundary layer that prepares Codex sessions and verifies Git diffs.", + "license": "MIT", + "homepage": "https://github.com/DerMayer1/Fencier#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/DerMayer1/Fencier.git", + "directory": "packages/cli" + }, + "bugs": { + "url": "https://github.com/DerMayer1/Fencier/issues" + }, + "keywords": ["codex", "ai-agents", "policy-as-code", "git", "security"], "type": "module", + "engines": { + "node": ">=22" + }, "bin": { "fencier": "./dist/index.js" }, "files": ["dist"], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsup", "dev": "tsx src/index.ts", diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 40b9e9f..4dac50c 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createProgram } from "./cli"; +import { createProgram, formatDoctorReport } from "./cli"; +import { formatCheckJson } from "./output"; describe("createProgram", () => { it("configures the fencier CLI", () => { @@ -49,4 +50,60 @@ describe("createProgram", () => { expect.arrayContaining(["list", "show", "path", "install"]), ); }); + + it("exposes safe JSON verification output", () => { + const program = createProgram(); + const verify = program.commands.find((command) => command.name() === "verify"); + + expect(verify?.options.map((option) => option.long)).toContain("--json"); + }); +}); + +describe("runtime output", () => { + it("reports concrete doctor checks", () => { + const output = formatDoctorReport({ + version: "0.1.0", + nodeVersion: "22.0.0", + nodeSupported: true, + gitVersion: "git version 2.50.0", + ready: true, + }); + + expect(output).toContain("CLI: v0.1.0"); + expect(output).toContain("Node.js: PASS"); + expect(output).toContain("Status: READY"); + }); + + it("omits raw added lines from machine-readable output", () => { + const rawSecret = ["sk", "test", "secret", "value"].join("-"); + const secretLine = [["OPENAI", "API", "KEY"].join("_"), rawSecret].join("="); + const output = formatCheckJson({ + status: "fail", + risk: "high", + score: 50, + findings: [ + { + ruleId: "secret_pattern", + severity: "critical", + message: "Possible secret detected", + score: 50, + preview: "sk-t...redacted", + }, + ], + summary: { filesChanged: 1, linesAdded: 1, linesRemoved: 0, totalLinesChanged: 1 }, + evaluatedFiles: [ + { + path: "src/config.ts", + status: "modified", + additions: 1, + deletions: 0, + addedLines: [{ content: secretLine }], + }, + ], + }); + + expect(output).toContain("sk-t...redacted"); + expect(output).not.toContain(rawSecret); + expect(output).not.toContain("addedLines"); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d19ade7..ac3845c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { listAdapters } from "@fencier/adapters"; import { getChecklist, @@ -9,31 +11,46 @@ import { listPrompts, listSkills, } from "@fencier/codex-kit"; -import { createHealthCheck, evaluatePolicy, parsePolicyConfig } from "@fencier/core"; +import { evaluatePolicy, parsePolicyConfig } from "@fencier/core"; import { Command } from "commander"; +import cliPackage from "../package.json" with { type: "json" }; import { installAdapters, parseAdapterInstallSelection } from "./adapters"; import { listAuditFiles, readLatestAuditMarkdown, writeAuditReports } from "./audit"; import { createCodexBrief } from "./codex"; import { collectGitDiff, getGitBranch, getGitCommit, isInsideGitRepo } from "./git"; import { initializeFencier } from "./init"; -import { formatCheckResult } from "./output"; +import { formatCheckJson, formatCheckResult } from "./output"; import { getLocalSkillRoot, installSkills, parseSkillInstallSelection } from "./skills"; import { createCodexPrepareReport, createCodexRunbook } from "./workflow"; +const execFileAsync = promisify(execFile); + +export type DoctorReport = { + version: string; + nodeVersion: string; + nodeSupported: boolean; + gitVersion?: string; + ready: boolean; +}; + export function createProgram(): Command { const program = new Command(); program .name("fencier") .description("Local-first boundary layer for AI coding agents") - .version("0.0.0"); + .version(cliPackage.version); program .command("doctor") - .description("Check that the Fencier CLI is wired correctly") - .action(() => { - const health = createHealthCheck(); - console.log(`${health.name}: ${health.status}`); + .description("Check the installed CLI, Node.js, and Git runtime") + .action(async () => { + const report = await inspectDoctor(); + console.log(formatDoctorReport(report)); + + if (!report.ready) { + process.exitCode = 3; + } }); program @@ -93,6 +110,7 @@ export function createProgram(): Command { .description("Alias for `verify`; evaluate the current git diff against fencier.yaml") .option("--staged", "Evaluate staged changes only") .option("--base ", "Evaluate changes against a base ref") + .option("--json", "Print machine-readable JSON without raw added lines") .option("--no-audit", "Skip writing local audit reports") .action(runVerification); @@ -101,6 +119,7 @@ export function createProgram(): Command { .description("Run deterministic verification for the current Codex-produced diff") .option("--staged", "Verify staged changes only") .option("--base ", "Verify changes against a base ref") + .option("--json", "Print machine-readable JSON without raw added lines") .option("--no-audit", "Skip writing local audit reports") .action(runVerification); @@ -323,6 +342,38 @@ export function createProgram(): Command { return program; } +export async function inspectDoctor(): Promise { + const nodeVersion = process.versions.node; + const nodeSupported = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10) >= 22; + let gitVersion: string | undefined; + + try { + gitVersion = (await execFileAsync("git", ["--version"], { windowsHide: true })).stdout.trim(); + } catch { + gitVersion = undefined; + } + + return { + version: cliPackage.version, + nodeVersion, + nodeSupported, + gitVersion, + ready: nodeSupported && Boolean(gitVersion), + }; +} + +export function formatDoctorReport(report: DoctorReport): string { + return [ + "Fencier Doctor", + "", + `CLI: v${report.version}`, + `Node.js: ${report.nodeSupported ? "PASS" : "FAIL"} (v${report.nodeVersion}; requires >=22)`, + `Git: ${report.gitVersion ? `PASS (${report.gitVersion})` : "FAIL (not available)"}`, + "", + `Status: ${report.ready ? "READY" : "NOT_READY"}`, + ].join("\n"); +} + function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } @@ -371,6 +422,7 @@ function parsePromptId(value: string): Parameters[1] async function runVerification(options: { staged?: boolean; base?: string; + json?: boolean; audit?: boolean; }): Promise { const cwd = process.cwd(); @@ -381,50 +433,47 @@ async function runVerification(options: { return; } - let policyText: string; - try { - policyText = await readFile(join(cwd, "fencier.yaml"), "utf8"); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - console.error("Missing fencier.yaml. Run `fencier init` first."); - process.exitCode = 3; - return; - } - - throw error; - } - - const policy = parsePolicyConfig(policyText); - const files = await collectGitDiff({ - cwd, - staged: options.staged, - base: options.base, - }); - const result = evaluatePolicy({ policy, files }); - - console.log(formatCheckResult(result)); - - if (options.audit !== false) { - const written = await writeAuditReports({ + const policyText = await readFile(join(cwd, "fencier.yaml"), "utf8"); + const policy = parsePolicyConfig(policyText); + const files = await collectGitDiff({ cwd, - policy, - result, - repo: { - path: cwd, - branch: await getGitBranch(cwd), - commit: await getGitCommit(cwd), - }, + staged: options.staged, + base: options.base, }); + const result = evaluatePolicy({ policy, files }); + + console.log(options.json ? formatCheckJson(result) : formatCheckResult(result)); + + if (options.audit !== false) { + const written = await writeAuditReports({ + cwd, + policy, + result, + repo: { + path: cwd, + branch: await getGitBranch(cwd), + commit: await getGitCommit(cwd), + }, + }); - if (written.markdownPath) { - console.log(""); - console.log(`Audit report: ${written.markdownPath}`); + if (written.markdownPath && !options.json) { + console.log(""); + console.log(`Audit report: ${written.markdownPath}`); + } } - } - if (result.status === "fail") { - process.exitCode = 1; + if (result.status === "fail") { + process.exitCode = 1; + } + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + console.error("Missing fencier.yaml. Run `fencier init` first."); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exitCode = 3; } } diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index c5175e3..8d08446 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -26,3 +26,14 @@ export function formatCheckResult(result: PolicyResult): string { return lines.join("\n"); } + +export function formatCheckJson(result: PolicyResult): string { + return JSON.stringify( + { + ...result, + evaluatedFiles: result.evaluatedFiles.map(({ addedLines: _addedLines, ...file }) => file), + }, + null, + 2, + ); +} diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..59bab39 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const workspace = await mkdtemp(join(tmpdir(), "fencier-release-check-")); +const tarballDir = join(workspace, "tarball"); +const targetRepo = join(workspace, "target"); +async function run(file, args, cwd, expectedCodes = [0]) { + try { + const result = await execFileAsync(file, args, { cwd, windowsHide: true }); + return { code: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const code = typeof error.code === "number" ? error.code : -1; + + if (expectedCodes.includes(code)) { + return { code, stdout: error.stdout ?? "", stderr: error.stderr ?? "" }; + } + + throw new Error(`${basename(file)} failed with exit code ${code}: ${error.stderr ?? ""}`); + } +} + +async function runPackageCommand(name, args, cwd) { + if (process.platform === "win32") { + return run(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", name, ...args], cwd); + } + + return run(name, args, cwd); +} + +try { + await mkdir(tarballDir, { recursive: true }); + await mkdir(targetRepo, { recursive: true }); + await runPackageCommand("pnpm", ["build"], process.cwd()); + await runPackageCommand( + "pnpm", + ["--filter", "@fencier/cli", "pack", "--pack-destination", tarballDir], + process.cwd(), + ); + + const tarball = (await readdir(tarballDir)).find((file) => file.endsWith(".tgz")); + if (!tarball) throw new Error("CLI pack did not produce a tarball."); + + await writeFile( + join(targetRepo, "package.json"), + `${JSON.stringify({ name: "fencier-release-check", private: true }, null, 2)}\n`, + "utf8", + ); + await runPackageCommand( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", join(tarballDir, tarball)], + targetRepo, + ); + + const cli = join(targetRepo, "node_modules", "@fencier", "cli", "dist", "index.js"); + const doctor = await run(process.execPath, [cli, "doctor"], targetRepo); + if (!doctor.stdout.includes("Status: READY")) throw new Error("Installed CLI doctor failed."); + + await run("git", ["init"], targetRepo); + await run(process.execPath, [cli, "init", "--codex"], targetRepo); + await run(process.execPath, [cli, "codex", "skill", "install", "all"], targetRepo); + await run("git", ["add", "."], targetRepo); + await run( + "git", + ["-c", "user.name=Fencier", "-c", "user.email=fencier@invalid", "commit", "-m", "baseline"], + targetRepo, + ); + + await mkdir(join(targetRepo, "src"), { recursive: true }); + await writeFile(join(targetRepo, "src", "index.ts"), "export const ready = true;\n", "utf8"); + const passing = await run(process.execPath, [cli, "verify", "--json", "--no-audit"], targetRepo); + if (JSON.parse(passing.stdout).status !== "pass") throw new Error("PASS fixture did not pass."); + + await run("git", ["add", "src/index.ts"], targetRepo); + await run( + "git", + ["-c", "user.name=Fencier", "-c", "user.email=fencier@invalid", "commit", "-m", "safe"], + targetRepo, + ); + + const syntheticKey = ["sk", "fencier", "release", "check", "1234567890"].join("-"); + const assignment = [["OPENAI", "API", "KEY"].join("_"), syntheticKey].join("="); + await writeFile(join(targetRepo, ".env"), `${assignment}\n`, "utf8"); + const failing = await run( + process.execPath, + [cli, "verify", "--json", "--no-audit"], + targetRepo, + [1], + ); + const failedResult = JSON.parse(failing.stdout); + if (failedResult.status !== "fail") throw new Error("FAIL fixture did not fail."); + if (failing.stdout.includes(syntheticKey)) throw new Error("JSON output exposed a raw secret."); + + console.log("Release check passed: packed install, doctor, init, skills, PASS, and safe FAIL."); +} finally { + const tempRoot = `${resolve(tmpdir())}${sep}`; + const resolvedWorkspace = resolve(workspace); + if ( + resolvedWorkspace.startsWith(tempRoot) && + basename(resolvedWorkspace).startsWith("fencier-release-check-") + ) { + await rm(resolvedWorkspace, { recursive: true, force: true }); + } +} From 438d878115283cc31e8403a714d3a1fc928d9269 Mon Sep 17 00:00:00 2001 From: Lucas Zimmermann Date: Mon, 27 Jul 2026 15:47:40 -0300 Subject: [PATCH 2/2] fix(audit): honor audit.include_patch instead of ignoring it The audit.include_patch policy flag was validated by the schema, shipped in the default fencier.yaml, and documented in the README, but no code ever read it. Setting it to true had no effect: patches were never written under any configuration. collectGitDiff already ran `git diff` and discarded the patch text after extracting added lines. It now returns { files, patch } and takes an includePatch option, re-reading the diff with default context so the stored patch is reviewable (the scan patch uses --unified=0 and has no context lines). The extra git call only runs when audits are actually being written. writeAuditReports gates the patch on policy.audit.include_patch in one place, then emits it as a ```diff section in Markdown and a patch key in JSON. The patch is the only unmasked part of an audit, so a diff that adds a secret now stores it in .fencier/audits/ in plaintext when the flag is on. Documented in the README security section, docs/security.md, and inline in the generated report. The default stays false. Also documents three commands missing from the README reference: codex install, codex fix-audit brief, and codex skill path. Co-Authored-By: Claude Opus 5 --- README.md | 5 +++ docs/security.md | 2 + packages/cli/src/audit.test.ts | 75 ++++++++++++++++++++++++++++++++++ packages/cli/src/audit.ts | 19 +++++++++ packages/cli/src/cli.ts | 9 ++-- packages/cli/src/git.test.ts | 21 +++++++++- packages/cli/src/git.ts | 31 +++++++++++--- 7 files changed, 151 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 23fdb7c..c4ab50d 100644 --- a/README.md +++ b/README.md @@ -272,12 +272,15 @@ This split keeps policy decisions unit-testable and makes every report traceable |---|---| | `fencier init --codex` | Initialize policy, audit workspace, and the Codex adapter | | `fencier doctor` | Check the installed CLI version, Node.js requirement, and Git runtime | +| `fencier codex install` | Install the Codex `AGENTS.md` adapter on its own | | `fencier codex prepare` | Check policy, adapter, local skills, and latest audit readiness | | `fencier codex brief` | Print deterministic repository context for Codex | | `fencier codex runbook ` | Compose a complete task runbook | +| `fencier codex fix-audit brief` | Print the runbook for fixing the latest audit | | `fencier codex prompt list\|show` | Inspect versioned prompt templates | | `fencier codex checklist list\|show` | Inspect task checklists | | `fencier codex skill list\|show\|install` | Inspect and install repository-local skills | +| `fencier codex skill path` | Print the local skill installation directory | | `fencier verify` | Evaluate the current diff and write configured audits; supports safe `--json` output | | `fencier check` | Compatibility alias for `verify` | | `fencier audit list` | List local audit reports | @@ -297,6 +300,8 @@ Fencier is local-first by design: - full patches are excluded from audits by default; - all decisions are derived from local policy and local Git state. +Setting `audit.include_patch: true` opts into embedding the raw diff in audit reports. That patch is not masked, so a diff that adds a secret will store it in `.fencier/audits/` in plaintext. The default is `false`. + Fencier is a boundary verifier, not a complete secret scanner or security analyzer. Human review remains required, especially for authentication, payments, CI/CD, migrations, and infrastructure changes. See the [security model](docs/security.md) for the complete contract. ## What Fencier is not diff --git a/docs/security.md b/docs/security.md index 53f75e8..0ae147b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,6 +16,8 @@ The current scanner checks added lines from git patches and untracked files. It Audit reports are written to `.fencier/audits/` as Markdown and JSON. Full patches are not included by default. +Setting `audit.include_patch: true` embeds the raw `git diff` in both report formats. That patch is the one part of an audit that is **not** masked: if the diff adds a secret, the secret appears in `.fencier/audits/` in plaintext. Keep the default unless the audit directory is git-ignored and treated as sensitive local state. + ## Limitations Fencier verification does not prove a change is safe. It identifies risk signals and policy violations so humans and Codex can review or correct faster. diff --git a/packages/cli/src/audit.test.ts b/packages/cli/src/audit.test.ts index d21f8fd..bf0a216 100644 --- a/packages/cli/src/audit.test.ts +++ b/packages/cli/src/audit.test.ts @@ -69,4 +69,79 @@ describe("writeAuditReports", () => { expect(contents).not.toContain("abcdefghijklmnop"); expect(contents).not.toContain("addedLines"); }); + + it("omits the patch unless audit.include_patch is enabled", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-audit-excluded-")); + + await writeAuditReports({ + cwd, + policy: parsePolicyConfig("version: 1\n"), + result: createCleanResult(), + repo: { path: cwd }, + patch: "diff --git a/src/a.ts b/src/a.ts\n+const token = 1;\n", + }); + + const contents = await readAuditPair(cwd); + + expect(contents.json).not.toContain('"patch"'); + expect(contents.markdown).not.toContain("## Patch"); + expect(contents.markdown).not.toContain("const token = 1;"); + }); + + it("writes the patch when audit.include_patch is enabled", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-audit-patch-")); + + await writeAuditReports({ + cwd, + policy: parsePolicyConfig("version: 1\naudit:\n include_patch: true\n"), + result: createCleanResult(), + repo: { path: cwd }, + patch: "diff --git a/src/a.ts b/src/a.ts\n+const token = 1;\n", + }); + + const contents = await readAuditPair(cwd); + + expect(contents.json).toContain("const token = 1;"); + expect(contents.markdown).toContain("## Patch"); + expect(contents.markdown).toContain("```diff"); + expect(contents.markdown).toContain("const token = 1;"); + }); }); + +function createCleanResult(): PolicyResult { + return { + status: "pass", + risk: "low", + score: 0, + summary: { + filesChanged: 1, + linesAdded: 1, + linesRemoved: 0, + totalLinesChanged: 1, + }, + findings: [], + evaluatedFiles: [ + { + path: "src/a.ts", + status: "modified", + additions: 1, + deletions: 0, + }, + ], + }; +} + +async function readAuditPair(cwd: string): Promise<{ json: string; markdown: string }> { + const auditDir = join(cwd, ".fencier", "audits"); + const auditFiles = await readdir(auditDir); + const jsonAudit = auditFiles.find((file) => file.endsWith(".json")); + const markdownAudit = auditFiles.find((file) => file.endsWith(".md")); + + expect(jsonAudit).toBeDefined(); + expect(markdownAudit).toBeDefined(); + + return { + json: await readFile(join(auditDir, jsonAudit as string), "utf8"), + markdown: await readFile(join(auditDir, markdownAudit as string), "utf8"), + }; +} diff --git a/packages/cli/src/audit.ts b/packages/cli/src/audit.ts index 2e9169e..7090d8b 100644 --- a/packages/cli/src/audit.ts +++ b/packages/cli/src/audit.ts @@ -23,6 +23,12 @@ export type AuditEvent = { summary: PolicyResult["summary"]; findings: PolicyFinding[]; changedFiles: DiffFile[]; + /** + * Raw patch text, written only when `audit.include_patch` is enabled. + * Unlike every other audit field, this is not masked and may contain + * secret values in plaintext. + */ + patch?: string; }; export type WrittenAudit = { @@ -36,6 +42,7 @@ export async function writeAuditReports(input: { policy: FencierPolicy; result: PolicyResult; repo: RepoContext; + patch?: string; }): Promise { const timestamp = new Date().toISOString(); const id = timestamp.replace(/[:.]/g, "-"); @@ -45,6 +52,7 @@ export async function writeAuditReports(input: { policy: input.policy, result: input.result, repo: input.repo, + patch: input.patch, }); const auditDir = join(input.cwd, ".fencier", "audits"); @@ -100,7 +108,10 @@ function createAuditEvent(input: { policy: FencierPolicy; result: PolicyResult; repo: RepoContext; + patch?: string; }): AuditEvent { + const patch = input.policy.audit.include_patch ? input.patch : undefined; + return { version: 1, id: input.id, @@ -121,6 +132,7 @@ function createAuditEvent(input: { additions: file.additions, deletions: file.deletions, })), + ...(patch ? { patch } : {}), }; } @@ -181,6 +193,13 @@ function formatAuditMarkdown(audit: AuditEvent): string { lines.push(`| \`${file.path}\` | ${file.status} | ${file.additions} | ${file.deletions} |`); } + if (audit.patch) { + lines.push("", "## Patch", ""); + lines.push("Written because `audit.include_patch` is enabled."); + lines.push("This section is unmasked and may contain secret values."); + lines.push("", "```diff", audit.patch.replace(/\n+$/, ""), "```"); + } + lines.push("", "## Policy", ""); lines.push(`Policy file: \`${audit.policy.file}\``); lines.push(`Policy version: ${audit.policy.version}`); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ac3845c..796acfe 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -436,20 +436,23 @@ async function runVerification(options: { try { const policyText = await readFile(join(cwd, "fencier.yaml"), "utf8"); const policy = parsePolicyConfig(policyText); - const files = await collectGitDiff({ + const writeAudit = options.audit !== false; + const diff = await collectGitDiff({ cwd, staged: options.staged, base: options.base, + includePatch: writeAudit && policy.audit.include_patch, }); - const result = evaluatePolicy({ policy, files }); + const result = evaluatePolicy({ policy, files: diff.files }); console.log(options.json ? formatCheckJson(result) : formatCheckResult(result)); - if (options.audit !== false) { + if (writeAudit) { const written = await writeAuditReports({ cwd, policy, result, + patch: diff.patch, repo: { path: cwd, branch: await getGitBranch(cwd), diff --git a/packages/cli/src/git.test.ts b/packages/cli/src/git.test.ts index 78dcf98..8b8ff6f 100644 --- a/packages/cli/src/git.test.ts +++ b/packages/cli/src/git.test.ts @@ -14,13 +14,30 @@ describe("collectGitDiff", () => { await execFileAsync("git", ["init"], { cwd, windowsHide: true }); await writeFile(join(cwd, "fencier.yaml"), "version: 1\n", "utf8"); - const files = await collectGitDiff({ cwd }); + const diff = await collectGitDiff({ cwd }); - expect(files).toContainEqual( + expect(diff.files).toContainEqual( expect.objectContaining({ path: "fencier.yaml", status: "added", }), ); }); + + it("returns patch text only when includePatch is requested", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fencier-patch-git-test-")); + await execFileAsync("git", ["init"], { cwd, windowsHide: true }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd }); + await execFileAsync("git", ["config", "user.name", "Fencier Test"], { cwd }); + await writeFile(join(cwd, "src.ts"), "export const a = 1;\n", "utf8"); + await execFileAsync("git", ["add", "-A"], { cwd, windowsHide: true }); + await execFileAsync("git", ["commit", "-m", "base"], { cwd, windowsHide: true }); + await writeFile(join(cwd, "src.ts"), "export const a = 2;\n", "utf8"); + + const withoutPatch = await collectGitDiff({ cwd }); + const withPatch = await collectGitDiff({ cwd, includePatch: true }); + + expect(withoutPatch.patch).toBeUndefined(); + expect(withPatch.patch).toContain("export const a = 2;"); + }); }); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 859a536..98364e8 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -10,6 +10,16 @@ export type GitDiffOptions = { cwd: string; staged?: boolean; base?: string; + includePatch?: boolean; +}; + +export type GitDiffResult = { + files: DiffFile[]; + /** + * Full-context patch text, collected only when `includePatch` is set. + * Untracked files are not represented; `git diff` does not report them. + */ + patch?: string; }; export async function isInsideGitRepo(cwd: string): Promise { @@ -37,27 +47,36 @@ export async function getGitCommit(cwd: string): Promise { } } -export async function collectGitDiff(options: GitDiffOptions): Promise { +export async function collectGitDiff(options: GitDiffOptions): Promise { if (!options.staged && !options.base && !(await hasGitHead(options.cwd))) { - return collectUntrackedFiles(options.cwd); + return { files: await collectUntrackedFiles(options.cwd) }; } const numstat = await execGit(options.cwd, [...createDiffArgs(options), "--numstat"]); const nameStatus = await execGit(options.cwd, [...createDiffArgs(options), "--name-status"]); - const patch = await execGit(options.cwd, [...createDiffArgs(options), "--unified=0"]); + const scanPatch = await execGit(options.cwd, [...createDiffArgs(options), "--unified=0"]); const statusByPath = parseNameStatus(nameStatus); - const addedLinesByPath = parseAddedLines(patch); + const addedLinesByPath = parseAddedLines(scanPatch); const trackedFiles = parseNumstat(numstat).map((file) => ({ ...file, status: statusByPath.get(file.path) ?? "unknown", addedLines: addedLinesByPath.get(file.path) ?? [], })); + // The scan patch drops context lines, so re-read the diff with default + // context when the policy asks for a reviewable patch. + const patch = options.includePatch + ? await execGit(options.cwd, createDiffArgs(options)) + : undefined; + if (options.staged || options.base) { - return trackedFiles; + return { files: trackedFiles, patch }; } - return [...trackedFiles, ...(await collectUntrackedFiles(options.cwd))]; + return { + files: [...trackedFiles, ...(await collectUntrackedFiles(options.cwd))], + patch, + }; } async function execGit(cwd: string, args: string[]): Promise {