Skip to content

feat(tools): add structured GitTool with schema-enforced safety#61

Draft
glitch-ux wants to merge 1 commit intoHKUDS:mainfrom
glitch-ux:feat/git-tool
Draft

feat(tools): add structured GitTool with schema-enforced safety#61
glitch-ux wants to merge 1 commit intoHKUDS:mainfrom
glitch-ux:feat/git-tool

Conversation

@glitch-ux
Copy link
Copy Markdown
Contributor

@glitch-ux glitch-ux commented Apr 7, 2026

Motivation

Today, every git operation in OpenHarness flows through BashTool as a raw command string. The permission system sees tool_name="bash" and a flat string — it has no idea whether the agent is running git status or git push --force. The only defense is manually enumerating glob patterns in denied_commands, which is brittle, easy to bypass with flag variants, and provides no structured context in approval dialogs.

This PR introduces a dedicated GitTool that replaces the raw-string approach with typed, Pydantic-validated operations. The core idea: if a dangerous flag doesn't exist in the schema, the model can't use it. Safety becomes a property of the tool's API surface, not a race between prompt instructions and glob patterns.

Before vs After

Scenario Before (BashTool) After (GitTool)
Force push BashTool(command="git push -f") — executes unless you pre-configured denied_commands: ["*push*-f*", "*push*--force*", "*push*--force-with-lease*", ...] GitTool(operation="push", ref="main") — no force field exists in the schema. Structurally impossible.
Skip pre-commit hooks BashTool(command="git commit --no-verify -m 'msg'") — executes unless glob-matched GitTool(operation="commit", message="msg") — no skip_hooks field. Handler always builds ["commit", "-m", message].
Stage everything blindly BashTool(command="git add -A") — stages secrets, binaries, everything GitTool(operation="add", files=["src/foo.py"]) — requires explicit file list. Validator rejects ., -A, --all. Handler rejects any - prefix.
Hard reset BashTool(command="git reset --hard HEAD~5") — destroys work Not expressible — reset is not in the operation enum.
Delete unmerged branch BashTool(command="git branch -D feature") — force-deletes GitTool(operation="branch_delete", ref="feature") — handler hardcodes -d (safe delete). Git itself refuses if the branch has unmerged work.
Permission dialog "Allow bash: git push origin main --force?" — user must parse the raw string "Allow git push to origin?" — structured, the tool name and operation are explicit
Read-only auto-approval All git commands require confirmation (BashTool is never read-only) status, diff, log, show, blame, branch_list return is_read_only=True — auto-allowed in default permission mode
Denied tools granularity Can only deny all of bash (blocks everything, not just git) Can deny git specifically via denied_tools: ["git"] while keeping bash available for non-git work

How it works

The tool follows the LspTool pattern: a single operation: Literal[...] field selects one of 15 git operations, and a @model_validator enforces per-operation required fields.

15 operations supported:

  • Read-only (auto-approved): status, diff, log, show, blame, branch_list
  • Mutating (require confirmation): add, commit, push, pull, branch_create, branch_delete, checkout, stash, tag

Permission system integration:

  1. is_read_only() returns True for the 6 inspect operations — auto-allowed in default permission mode, no user prompt needed
  2. A @property command synthesizes a string like "git push origin main" that _extract_permission_command() in query.py picks up — so existing denied_commands glob patterns still work
  3. The tool name "git" integrates with denied_tools / allowed_tools for blanket control

Subprocess safety: Uses the same _run_git() pattern as swarm/worktree.pyGIT_TERMINAL_PROMPT=0 and GIT_ASKPASS="" prevent interactive prompts from hanging the agent. All handlers use -- before file arguments to prevent argument injection via filenames.

Files changed

File Change
src/openharness/tools/git_tool.py New — tool implementation (~280 lines)
src/openharness/tools/__init__.py Import + register in create_default_tool_registry()
tests/test_tools/test_git_tool.py New — 31 tests
CHANGELOG.md Entry under Unreleased

Test plan

  • uv run ruff check — all checks passed
  • uv run pytest tests/test_tools/test_git_tool.py -v31/31 passed
  • uv run pytest -q537 passed, 6 skipped, 1 xfailed, 0 regressions

Tests cover: all 15 operations end-to-end, input validation (rejected patterns like ., -A, --all, - prefixes), is_read_only correctness, command property synthesis, JSON schema exclusion of command, registry integration, and non-git-repo error handling.

Replace raw git-via-BashTool with a dedicated GitTool that exposes
typed, Pydantic-validated operations. Dangerous git commands cannot
be expressed in the schema — safety is structural, not prompt-based.

Supports 15 operations: status, diff, log, show, blame, branch_list,
add, commit, push, pull, branch_create, branch_delete, checkout,
stash, and tag. Includes 31 tests covering all operations, input
validation, safety constraints, permission integration, and registry.
@glitch-ux
Copy link
Copy Markdown
Contributor Author

To expand on the motivation: the key shift here is moving git safety from pattern matching to schema design.

Today, the permission system can only react to git commands after they're already composed as strings — matching against denied_commands globs. This is inherently a denylist approach: you block what you think of, and everything else passes through.

The GitTool inverts this to an allowlist model. The Pydantic schema defines exactly what the model can express — 15 operations, each with typed fields. Dangerous flags like --force, --no-verify, --hard, and -D don't exist as fields, so they can't be requested. The permission layer then gains real granularity:

  • Per-operation read/write classification: status, diff, log are auto-approved; commit, push require confirmation
  • Tool-level control: denied_tools: ["git"] blocks git without touching bash
  • Backward-compatible: the synthesized command property still feeds into existing denied_commands patterns

Net result: fewer permission prompts for safe operations, zero surface area for destructive ones.

@glitch-ux glitch-ux marked this pull request as draft April 10, 2026 20:52
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