Skip to content

refactor: migrate 6 handler groups onto declared safety policies (step 3a)#7

Open
stevehansen wants to merge 1 commit into
feat/safety-policy-foundationfrom
feat/safety-policy-step3a
Open

refactor: migrate 6 handler groups onto declared safety policies (step 3a)#7
stevehansen wants to merge 1 commit into
feat/safety-policy-foundationfrom
feat/safety-policy-step3a

Conversation

@stevehansen
Copy link
Copy Markdown
Member

What & why

Step 3 of the deep Safety Policy RFC (specs/RFC-safety-policy.md), stacked on the foundation in #6. The foundation introduced the Policy vocabulary, the domain ports, and central enforcement at the dispatch site, but migrated only bun as a proof of concept. This PR migrates the inline, hand-rolled safety validation in 6 of the remaining groupsgit, db, docker, process, npm, pnpm — onto declarative Policy chains.

Each handler's safety check (OutputFormatter.WriteBlocked blocks, blocklist foreach loops, FilterFlags, the git clean-tree / not-a-repo / amend-already-pushed probes) becomes data attached at registration and evaluated once before the handler runs. Handlers keep only their usage checks and the actual execution.

What landed

  • gitRequireGitRepo() on every command (the probe spawns git once per run, not ~25×); push/commit/add/checkout/checkout-file block rules; commit-amendRequireHeadNotPushed(); pull/checkout/mergeRequireCleanTree(); log/diff flag-allowlists → AllowOnlyFlags rewrite.
  • db — the 4 migration commands → BlockFlags(DestructiveFlags); artisan-migrateBlockSubstrings; django-migrateBlockFlags(["zero"]).
  • dockercompose-downBlockFlags; build/compose-up FilterFlagsAllowOnlyFlags.
  • processkill-nameAllowOnlyFirstArg.
  • npm/pnpmrunAllowOnlyFirstArg; npm audit-fix --forceBlockFlags.
  • dedup — the 3×-duplicated run-script allowlist (npm/pnpm/bun) collapses into one PackageScripts.Allowed.

Defects closed (for these groups)

Out of scope (follow-up PRs)

Behavior deltas (intentional, RFC-sanctioned)

RequireGitRepo "Not a git repository" now renders as a Blocked: envelope rather than a plain Error:; block messages show the whole arg vector; db block reasons are generic; kill-name lists 15 names instead of 10; docker FilterFlags unified onto the explicit value-flag algorithm; artisan/django blocks are now case-insensitive (strictly safer); safety runs before a handler's usage check.

Tests

92 → 157 passing, green across repeated runs. New MigratedCommandPolicyTests.cs asserts each migrated command's wired policy directly (allowed/rewrite cases never dispatch, to avoid spawning real tools); blocked-path + --json envelope are checked through the dispatcher.

Review notes resolved

Reviewer flagged a flaky suite: CommandRegistry.Initialize() was neither idempotent nor thread-safe, and the new parallel test collections raced on the shared list. Fixed at the root (double-checked guard); harmless to production's single startup call.

🤖 Generated with Claude Code

…olicies

Step 3 of the deep Safety Policy RFC (specs/RFC-safety-policy.md), building on
the foundation (#6). Moves the hand-rolled safety checks in git, db, docker,
process, npm, and pnpm onto declarative Policy chains attached at registration
and enforced once at the dispatch site. Handlers keep only usage checks and the
actual execution; the safety verdict is now data, not scattered control flow.

Closes two latent defects for these groups:
- #1 --flag=value bypass: BlockFlags normalizes via Flag.Base, so --force=true,
  --accept-data-loss=..., etc. are now caught (git push, db migrations, npm
  audit-fix), where the old exact-token blocklists let them through.
- #3 --json blocked-envelope fork: blocked output now renders through the central
  dispatcher (ConsoleRenderer.Blocked, which has a JSON branch), so
  `safe git push --force --json` emits a structured envelope instead of markup.

Also collapses the 3x-duplicated run-script allowlist (npm/pnpm/bun) into a single
PackageScripts.Allowed, and makes CommandRegistry.Initialize() idempotent and
thread-safe -- harmless under production's single startup call, but the new
parallel test collections raced on the shared list.

Out of scope (deferred to follow-up PRs): proxy AllowedFlags enforcement (defect
#2, which also requires untangling the Program.cs dispatch bypass) and
file/generate path-containment -- both still validate inline.

Tests: 92 -> 157, green across repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the validation logic across several command groups (Git, DB, Docker, Process, NPM, PNPM, Bun) by migrating from manual inline checks to a declarative, policy-based framework. It also consolidates the allowed package scripts into a shared class, makes the command registry initialization thread-safe, and adds a comprehensive test suite. The review feedback is highly constructive, pointing out opportunities to prevent security bypasses by blocking the git push '-d' short flag, ensuring consistency by blocking the '*' wildcard in git add, and improving usability by allowing multiple files to be restored in git checkout-file.

private static readonly HashSet<string> LogAllowedFlags = ["-n", "--oneline", "--graph", "--format", "--pretty", "--author", "--since", "--until", "--all", "--stat", "--no-merges", "--first-parent", "--reverse", "--abbrev-commit", "--date"];
private static readonly HashSet<string> DiffAllowedFlags = ["--staged", "--cached", "--name-only", "--name-status", "--stat", "--shortstat", "--numstat", "--diff-filter", "--no-color", "--color=never", "--unified", "-U"];
private static readonly HashSet<string> GitValueFlags = ["-n", "--format", "--pretty", "--author", "--since", "--until", "--date", "--diff-filter", "--unified", "-U"];
private static readonly HashSet<string> PushBlockedFlags = ["--force", "-f", "--delete", "--no-verify"];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The PushBlockedFlags set is missing the short flag -d, which is the standard alias for --delete in git push. Without blocking -d, users could bypass the deletion block by running safe git push origin -d . Please add -d to the blocked flags set to prevent this bypass.

    private static readonly HashSet<string> PushBlockedFlags = ["--force", "-f", "--delete", "-d", "--no-verify"];

private static readonly HashSet<string> GitValueFlags = ["-n", "--format", "--pretty", "--author", "--since", "--until", "--date", "--diff-filter", "--unified", "-U"];
private static readonly HashSet<string> PushBlockedFlags = ["--force", "-f", "--delete", "--no-verify"];
private static readonly HashSet<string> CommitBlockedFlags = ["--no-verify"];
private static readonly HashSet<string> AddBlockedArgs = ["-A", "--all", "."];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The AddBlockedArgs set blocks ., but does not block the wildcard *. While shell expansion often resolves * before it reaches the application, blocking * explicitly ensures consistency with checkout-file (which blocks both . and *) and guards against staging all files if the wildcard is passed literally. Consider adding * to AddBlockedArgs.

    private static readonly HashSet<string> AddBlockedArgs = ["-A", "--all", ".", "*"];

return 1;
}

return RunGit(["checkout", "--", args[0]], json);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, RunCheckoutFile only restores the first file (args[0]) and ignores any subsequent files passed to the command. Since the policy BlockFlags([., *]) evaluates all arguments in the vector, it is completely safe to pass all arguments to RunGit using the spread operator (..args). This will allow users to restore multiple files at once (e.g., safe git checkout-file file1.cs file2.cs).

        return RunGit(["checkout", "--", ..args], json);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant