Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ jobs:

- name: Build
run: pnpm build

- name: Validate installed CLI package
run: pnpm release:check
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -264,14 +271,17 @@ 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 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 <id>` | 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 verify` | Evaluate the current diff and write configured audits |
| `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 |
| `fencier audit show latest` | Print the latest Markdown audit |
Expand All @@ -290,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
Expand All @@ -308,6 +320,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:
Expand All @@ -318,11 +331,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;
Expand All @@ -331,14 +344,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

Expand Down
2 changes: 2 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};
}
19 changes: 19 additions & 0 deletions packages/cli/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,6 +42,7 @@ export async function writeAuditReports(input: {
policy: FencierPolicy;
result: PolicyResult;
repo: RepoContext;
patch?: string;
}): Promise<WrittenAudit> {
const timestamp = new Date().toISOString();
const id = timestamp.replace(/[:.]/g, "-");
Expand All @@ -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");

Expand Down Expand Up @@ -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,
Expand All @@ -121,6 +132,7 @@ function createAuditEvent(input: {
additions: file.additions,
deletions: file.deletions,
})),
...(patch ? { patch } : {}),
};
}

Expand Down Expand Up @@ -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}`);
Expand Down
59 changes: 58 additions & 1 deletion packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading
Loading