You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Refactor the ~80 command handlers in SafeCommands so safety claims become testable and cross-handler duplication of git state probes goes away. Concretely: introduce three ports (IExecutor, IRenderer, IGitRepo), wire them via constructor injection through a Ports record, and layer a thin Run.* sugar facade on top so the 50% pure-passthrough case stays a one-liner. No new dependencies; no DI container; no middleware.
This RFC is the result of an architectural exploration that compared four alternative designs (minimal interface, max-flexibility framework, common-case sugar, ports-and-adapters). The chosen design is a hybrid: ports-and-adapters as the foundation, with common-case sugar layered on top.
Motivation
Two architectural pains, recurring across the codebase:
Pain 1 — Command execution boilerplate. Roughly 80 handlers repeat the same six-line ritual:
Identical blocks live in BunCommands.cs:34-45, DotnetCommands.cs:35-46, DockerCommands.cs:41-52. Refactoring the JSON shape today means touching ~80 sites. ProcessRunner.RunPassthrough (Infrastructure/ProcessRunner.cs:48-66) is dead code.
Pain 2 — Safety validation scattered. The same shape ("match args/state against a deny rule, emit structured Blocked(reason, suggestion)") is reimplemented across seven files in three different idioms:
The deeper structural problem behind both pains: safety claims are unverifiable. The codebase has zero tests, and handlers cannot be tested without spawning real processes — they call static ProcessRunner.Run directly, with no seam. Today there is no way to assert "safe git push --force is blocked" without invoking the full CLI as a subprocess. STRIDE.md tracks this implicitly through R1 (no audit trail, score 9), but the test gap is the underlying enabler.
IGitRepo is the port that earns its keep — it deduplicates git state probes that today live in bothGitCommands and FileCommands. The three rev-parse calls in RunCommitAmend and the ls-files/diff calls in RunDeleteTracked move behind this port.
Adapters
ProcessExecutor — instance wrapper over the existing ProcessRunner.Run mechanics. Behavioural no-op.
ConsoleRenderer — wraps OutputFormatter. Constructed with the json flag.
GitRepoAdapter — takes IExecutor in its constructor; answers structured questions via git invocations.
Policy is a pure record with fluent builders (DenyFlags, AllowOnlyScripts, SandboxPath, Require). Its Evaluate(args) method is unit-testable in isolation with no infrastructure.
Verbosity gradient (illustrative)
// Pure passthrough — 1 linepublicintRunBun(string[]args)=>Run.Bun(_ports,"run",args);// Flag-block + run — 1 linepublicintRunPush(string[]args)=>Run.Git(_ports,"push",args,Policy.Default.DenyFlags("--force","-f"));// Multi-step probe via IGitRepo — drops to raw portspublicintRunCommitAmend(string[]args){if(!_git.IsRepo()){_render.Blocked("Not a git repository",null);return1;}varhead=_git.GetHeadStatus();if(head.IsPushed){_render.Blocked($"HEAD is pushed to {head.Upstream}; amending would rewrite published history.","Make a new commit instead.");return1;}returnRun.Git(_ports,"commit",["--amend", ..args]);}
Four parallel designs were drafted with different design constraints; only the hybrid above was selected.
Minimal interface — single Safe.Execute(SafeSpec) entry point with a fluent record. Rejected: SafeSpec becomes a kitchen sink of five orthogonal concerns; multi-step probes are positionally addressed (ctx.Probes[0], [1], [2]); Generate group requires a synthetic Tool = "internal:generate" adapter, leaving two idioms in the codebase.
Maximum flexibility framework — ICommandHandler + IPolicy + IProbe + IExecutor + IOutputParser + IRenderer + ICommandMiddleware + DI host. Rejected: seven new concepts to learn; assumes Microsoft.Extensions.DependencyInjection (violates the "no new deps" constraint); plugin-assembly loading is a security smell for a tool whose pitch is safety. Audit logging (its big win) can be added later as a fourth port without restructuring.
Common-case sugar only — static Run.Tool facade with settable Run.Executor. Rejected as a standalone design: settable static makes parallel tests unsafe; Policy grows tool-specific methods on a generic type. Adopted as the sugar layer on top of the ports design.
Test impact
Currently: zero tests. After this refactor:
[Fact]publicvoidGitPush_ForceFlag_IsBlocked_WithoutSpawningProcess(){varexec=newFakeExecutor().Default(0,"","");varrender=newFakeRenderer();vargit=newFakeGitRepo();varsut=newGitCommands(newPorts(exec,render,git));varrc=sut.RunPush(new[]{"--force","origin","main"});Assert.Equal(1,rc);Assert.Empty(exec.Calls);// never spawned gitAssert.Contains("--force is blocked",render.BlockedReasons[0]);}[Fact]publicvoidCommitAmend_Blocked_WhenHeadIsPushed(){vargit=newFakeGitRepo().AsRepo().WithPushedHead("main","origin/main");varsut=newGitCommands(newPorts(newFakeExecutor(),newFakeRenderer(),git));Assert.Equal(1,sut.RunCommitAmend(new[]{"-m","tweak"}));}
The previously-impossible assertion — Assert.Empty(exec.Calls) after a blocked policy — becomes a one-liner. Estimated test count after the refactor: ~50–80 policy unit tests + ~15 IGitRepo-driven handler tests, all sub-millisecond.
Migration plan
Phased, mergeable in slices. Each phase ships independently and leaves the codebase in a working state.
Phase 1 — Add ports and adapters. Land IExecutor, IRenderer, IGitRepo plus ProcessExecutor, ConsoleRenderer, GitRepoAdapter. No handler changes yet. Wire Ports in Program.cs but keep static ProcessRunner.Run / OutputFormatter.* as a transitional shim. (~150 LOC added, 0 changed.)
Phase 2 — Add Policy + Run sugar. Land Policy record, Run.Tool / Run.Bun / Run.Git / etc. Add a small test project. Migrate one command group end-to-end (suggest BunCommands — small, all passthroughs) as a worked example.
Phase 3 — Migrate flag-block groups.Dotnet, Docker, Npm, Pnpm, Db. Collapse the duplicated AllowedScripts HashSets into a single shared one. Add policy unit tests as each migrates.
Phase 4 — Migrate GitCommands and FileCommands. This is where IGitRepo pays off: the three rev-parse calls in RunCommitAmend and the two probes in RunDeleteTracked move behind GetHeadStatus / IsFileTracked / HasUnstagedChanges. Add tests using FakeGitRepo.
Phase 5 — Migrate ProxyCommands and enforce the allowlist. The flag allowlist in ProxyCommands.cs:12-144 is currently documentation only — never validated against incoming args. Migration adds real enforcement.
Phase 6 — Remove the transitional shim. Delete ProcessRunner.RunPassthrough (dead code). Static ProcessRunner.Run callers are gone; class can become internal or be deleted.
Streaming process output (e.g., live terraform plan rendering). IExecutor returns captured output; streaming would be a future second method.
DI container, middleware, plug-in assemblies. Explicitly rejected during design.
Async port surface. SafeCommands is a synchronous CLI; async is YAGNI.
Risks
IGitRepo could grow into a god-port. Counter: every method must be answerable by one git invocation, and policy stays out. New questions = new methods, not parameters.
Stack traces gain a frame. Acceptable; the test-ability win dominates.
Spectre.Console's static AnsiConsole is a back door around IRenderer. A code-review convention (or a Roslyn analyzer) is needed to keep new code from regressing to direct console writes.
Migration across 80 handlers is real work. Mitigated by phasing; each phase is independently shippable.
Update STRIDE.md after merging
Per CLAUDE.md, update STRIDE.md once this lands: the seam introduced here is a prerequisite for resolving R1 (no audit trail) and improves verifiability of the existing T-class mitigations (flag blocks, path sandboxing).
Summary
Refactor the ~80 command handlers in SafeCommands so safety claims become testable and cross-handler duplication of git state probes goes away. Concretely: introduce three ports (
IExecutor,IRenderer,IGitRepo), wire them via constructor injection through aPortsrecord, and layer a thinRun.*sugar facade on top so the 50% pure-passthrough case stays a one-liner. No new dependencies; no DI container; no middleware.This RFC is the result of an architectural exploration that compared four alternative designs (minimal interface, max-flexibility framework, common-case sugar, ports-and-adapters). The chosen design is a hybrid: ports-and-adapters as the foundation, with common-case sugar layered on top.
Motivation
Two architectural pains, recurring across the codebase:
Pain 1 — Command execution boilerplate. Roughly 80 handlers repeat the same six-line ritual:
Identical blocks live in
BunCommands.cs:34-45,DotnetCommands.cs:35-46,DockerCommands.cs:41-52. Refactoring the JSON shape today means touching ~80 sites.ProcessRunner.RunPassthrough(Infrastructure/ProcessRunner.cs:48-66) is dead code.Pain 2 — Safety validation scattered. The same shape ("match args/state against a deny rule, emit structured Blocked(reason, suggestion)") is reimplemented across seven files in three different idioms:
GitCommands.cs:329— exact-matchHashSet(PushBlockedFlags)DbCommands.cs:75-88—BlockIfDestructivehelper, lowercases firstDbCommands.cs:199-211(RunArtisanMigrate) — inlinearg.Contains("fresh"|"reset"|"rollback"|"wipe")FileCommands.cs:36-55—ValidatePathfor path traversalGitCommands.cs:53-84—IsWorkingTreeClean,IsGitRepo,RequireCleanTreeGitCommands.cs:280-298—RunCommitAmendruns threerev-parsecalls to check whether HEAD is pushedFileCommands.cs:439-447—RunDeleteTrackedruns two more git probes (ls-files,diff)ProxyCommands.cs:177-189— inline curl POST/PUT blockNpmCommands,BunCommands,PnpmCommands— copy-pastedAllowedScriptsHashSetsThe deeper structural problem behind both pains: safety claims are unverifiable. The codebase has zero tests, and handlers cannot be tested without spawning real processes — they call static
ProcessRunner.Rundirectly, with no seam. Today there is no way to assert "safe git push --forceis blocked" without invoking the full CLI as a subprocess. STRIDE.md tracks this implicitly through R1 (no audit trail, score 9), but the test gap is the underlying enabler.Proposed design
Three ports
IGitRepois the port that earns its keep — it deduplicates git state probes that today live in bothGitCommandsandFileCommands. The threerev-parsecalls inRunCommitAmendand thels-files/diffcalls inRunDeleteTrackedmove behind this port.Adapters
ProcessExecutor— instance wrapper over the existingProcessRunner.Runmechanics. Behavioural no-op.ConsoleRenderer— wrapsOutputFormatter. Constructed with thejsonflag.GitRepoAdapter— takesIExecutorin its constructor; answers structured questions via git invocations.FakeExecutor— recording fake; predicate-keyed canned results; tests assert onCalls.FakeRenderer— accumulates rendered output andBlockedReasons.FakeGitRepo— fluent builder:new FakeGitRepo().AsRepo().WithCleanTree().WithPushedHead("main", "origin/main").Handler shape
Group classes take a
Portsrecord by constructor. The registry stores a factory rather than a static delegate.Portsis a record so adding a fourth port (IAuditLog,IClock) doesn't ripple through 80 signatures.Sugar layer for the 50% case
Policy
Policyis a pure record with fluent builders (DenyFlags,AllowOnlyScripts,SandboxPath,Require). ItsEvaluate(args)method is unit-testable in isolation with no infrastructure.Verbosity gradient (illustrative)
Composition root
Designs considered and rejected
Four parallel designs were drafted with different design constraints; only the hybrid above was selected.
Safe.Execute(SafeSpec)entry point with a fluent record. Rejected:SafeSpecbecomes a kitchen sink of five orthogonal concerns; multi-step probes are positionally addressed (ctx.Probes[0],[1],[2]);Generategroup requires a syntheticTool = "internal:generate"adapter, leaving two idioms in the codebase.ICommandHandler+IPolicy+IProbe+IExecutor+IOutputParser+IRenderer+ICommandMiddleware+ DI host. Rejected: seven new concepts to learn; assumesMicrosoft.Extensions.DependencyInjection(violates the "no new deps" constraint); plugin-assembly loading is a security smell for a tool whose pitch is safety. Audit logging (its big win) can be added later as a fourth port without restructuring.Run.Toolfacade with settableRun.Executor. Rejected as a standalone design: settable static makes parallel tests unsafe;Policygrows tool-specific methods on a generic type. Adopted as the sugar layer on top of the ports design.Test impact
Currently: zero tests. After this refactor:
The previously-impossible assertion —
Assert.Empty(exec.Calls)after a blocked policy — becomes a one-liner. Estimated test count after the refactor: ~50–80 policy unit tests + ~15IGitRepo-driven handler tests, all sub-millisecond.Migration plan
Phased, mergeable in slices. Each phase ships independently and leaves the codebase in a working state.
IExecutor,IRenderer,IGitRepoplusProcessExecutor,ConsoleRenderer,GitRepoAdapter. No handler changes yet. WirePortsinProgram.csbut keep staticProcessRunner.Run/OutputFormatter.*as a transitional shim. (~150 LOC added, 0 changed.)Policyrecord,Run.Tool/Run.Bun/Run.Git/ etc. Add a small test project. Migrate one command group end-to-end (suggestBunCommands— small, all passthroughs) as a worked example.Dotnet,Docker,Npm,Pnpm,Db. Collapse the duplicatedAllowedScriptsHashSets into a single shared one. Add policy unit tests as each migrates.GitCommandsandFileCommands. This is whereIGitRepopays off: the three rev-parse calls inRunCommitAmendand the two probes inRunDeleteTrackedmove behindGetHeadStatus/IsFileTracked/HasUnstagedChanges. Add tests usingFakeGitRepo.ProxyCommandsand enforce the allowlist. The flag allowlist inProxyCommands.cs:12-144is currently documentation only — never validated against incoming args. Migration adds real enforcement.ProcessRunner.RunPassthrough(dead code). StaticProcessRunner.Runcallers are gone; class can become internal or be deleted.Out of scope
IAuditLogport is mechanical once the seam exists; tracked separately under Add audit logging for command execution #1.terraform planrendering).IExecutorreturns captured output; streaming would be a future second method.Risks
IGitRepocould grow into a god-port. Counter: every method must be answerable by one git invocation, and policy stays out. New questions = new methods, not parameters.AnsiConsoleis a back door aroundIRenderer. A code-review convention (or a Roslyn analyzer) is needed to keep new code from regressing to direct console writes.Update STRIDE.md after merging
Per CLAUDE.md, update
STRIDE.mdonce this lands: the seam introduced here is a prerequisite for resolving R1 (no audit trail) and improves verifiability of the existing T-class mitigations (flag blocks, path sandboxing).