The first post in this series introduced git-fabric and why it exists. This post opens the hood on @git-fabric/cve — the first real app built on that philosophy — and walks through the five-layer architecture, the adapter interfaces that make it portable, and the gateway that ties everything together behind a single MCP connection.
Why Layers
Most security tools are monoliths. They scan, enrich, decide, and act in one pass. That’s fine until you want to do any one of those things independently — enrich a CVE you already know about, re-triage with a different severity policy, or inspect the queue without triggering a scan.
The @git-fabric/cve package splits the work into five discrete layers, each independently callable:
Detection -> Intelligence -> Decision -> Action -> State
| | | | |
Scan deps NVD enrich Policy Branch JSONL queue
query GHSA CVSS/CWE triage commit dedup
plans PR stats
The first three layers — Detection, Intelligence, Decision — are pure. They read data, transform it, and return results. No side effects. No branches created, no files written, no state mutated. You can call them in a test, in a dry run, or in a pipeline, and nothing changes in the outside world.
The last two layers — Action and State — are effectful. Action creates branches, commits dependency bumps, and opens PRs. State manages the persistent CVE queue. Side effects are isolated to these two layers, which means the blast radius of any bug is contained and auditable.
Walking the Layers
Detection reads dependency manifests and queries the GitHub Advisory Database (GHSA). Given a repository, it returns a list of raw advisory matches — CVE IDs, affected packages, version ranges. It doesn’t judge severity or decide what to do. It just finds what’s there.
Intelligence takes those raw advisories and enriches them from NVD. CVSS scores, severity ratings, CWE classifications, current status. The output is a fully characterized vulnerability — everything you need to make a decision, but no decision made yet.
Decision applies a severity policy to produce triage plans. The default policy is straightforward:
| Severity | Default Action | PR Type |
|---|---|---|
| CRITICAL | Auto-PR | Confirmed |
| HIGH | Auto-PR | Draft |
| MEDIUM | Skip | Manual review |
| LOW | Skip | Noise reduction |
Critical and high vulnerabilities get automatic PRs. Medium and low get logged but not acted on — a deliberate yarn over in the automation fabric, where human judgment matters more than speed. The policy is a plain object, not buried in conditionals. Swap it and the entire triage behavior changes.
Action takes a triage plan and executes it. Create a branch, commit the dependency bump, open the PR with the right labels and references. This is the only layer that talks to the GitHub API with write permissions.
State manages the CVE queue as a JSONL file. Deduplication, filtering, compaction, and statistics. Every scan appends; nothing overwrites. The queue is the audit trail.
The Project Structure
The code mirrors the architecture directly:
src/
types.ts # Shared types + adapter interfaces
layers/
detection.ts # GHSA scanning + manifest parsing
intelligence.ts # NVD enrichment
decision.ts # Severity policy engine
action.ts # Branch + commit + PR creation
state.ts # JSONL queue management
mcp/
server.ts # MCP server (7 tools)
adapters/
env.ts # Env var -> Octokit adapter
No barrel files hiding indirection. Each layer is one file. The directory structure is the architecture diagram.
Adapter Interfaces
Here’s the design choice that makes the whole thing portable. The CVE app doesn’t import Octokit directly. It doesn’t know how to authenticate with GitHub. It doesn’t know where the state file lives. Instead, it defines what it needs through adapter interfaces:
interface GitHubAdapter {
getAdvisories(owner: string, repo: string): Promise<Advisory[]>;
createBranch(owner: string, repo: string, ref: string): Promise<void>;
createPR(owner: string, repo: string, params: PRParams): Promise<PR>;
// ...
}
interface StateAdapter {
append(entry: QueueEntry): Promise<void>;
list(filter?: QueueFilter): Promise<QueueEntry[]>;
compact(retention: number): Promise<CompactResult>;
stats(): Promise<QueueStats>;
}
The app declares the shapes. The consumer provides the implementations. When git-steer consumes @git-fabric/cve, it passes an Octokit-backed GitHubAdapter with rate-limit throttling and retry logic baked in (critical, given a prior GitHub account suspension from aggressive automated API patterns). When tests consume it, they pass mocks. When a future GitHub Action consumes it, it can pass its own adapter with the GITHUB_TOKEN from the runner environment.
This is dependency inversion at the package boundary. The CVE app is a library with holes shaped like its dependencies. Plug in the right adapters and it runs anywhere — inside git-steer, inside Claude Desktop via MCP, inside CI, or standalone.
The Gateway
Individual fabric apps are useful on their own. But the real power shows up when you aggregate them behind a gateway.
The gateway has three parts:
Registry — an in-memory map of app names to loaded FabricApp instances. Each app registers itself with createApp(), which returns a standard contract:
interface FabricApp {
name: string;
version: string;
tools: ToolDefinition[];
health(): Promise<HealthStatus>;
}
Router — takes an incoming tool call, looks up which app owns that tool name, and dispatches the call. Health checks are aggregated across all registered apps. One route handler replaces N individual case blocks.
Loader — handles dynamic app loading with graceful degradation. If @git-fabric/cve fails to load (missing token, network error, bad config), the gateway continues without it. The core tools still work. The fabric tools simply don’t appear in the tool list.
In git-steer, the entire gateway integration is 65 lines. Initialize the gateway, register the CVE app, get back a handle. If registration fails, log it and move on. The tool list is built conditionally — 32 core tools always present, 6 fabric tools added only if the gateway loaded successfully.
MCP as the Surface
Every layer in the CVE app is exposed through MCP tools:
| MCP Tool | Layer | Side Effects |
|---|---|---|
cve_scan | Detection | No |
cve_enrich | Intelligence | No |
cve_triage | Decision | No |
cve_batch | Detection + Intelligence + Decision | No |
cve_queue_list | State | No |
cve_queue_stats | State | No |
cve_queue_update | State | Yes |
The cve_batch tool is a convenience that chains the three pure layers in sequence — scan, enrich, triage — but each layer remains independently callable. You can enrich a CVE you found through other means. You can re-triage with different policy parameters. The tools are composable because the layers are composable.
Through the gateway, these tools are available under a fabric_cve_* namespace. The gateway adds its own tools — fabric_health, fabric_apps, fabric_route — and aggregates everything into a single MCP connection. One server, N apps, all discoverable through ListTools.
Why This Matters
The bet behind this architecture is that N=2 is coming soon. The CVE app is the first fabric app. Compliance scanning, drift detection, dependency license auditing — they all follow the same pattern: layers that read, layers that decide, layers that act, adapters that abstract the environment, and a gateway that aggregates the surface.
When the second app lands, the gateway is already there. Register it, add tool definitions, extend the routing map. The infrastructure cost of going from one app to two is close to zero because the patterns were designed for composition from the start.
That’s the whole point of thinking in fabric rather than in monoliths. Every thread is independent. Every connection is loose enough to undo. And the loom holds it all under just enough tension to weave.
This is part 2 of the git-fabric series. Part 1: Introducing git-fabric. Source: github.com/git-fabric.