From 0886c9013cd5c7b008aa28d3bbb1709fdb83a607 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 10 Jul 2026 21:30:35 -0700 Subject: [PATCH] =?UTF-8?q?#620:=20git=5Fgate=20block=20messages=20guide?= =?UTF-8?q?=20instead=20of=20dead-end=20=E2=80=94=20explain=20WHY=20git=20?= =?UTF-8?q?is=20enforced,=20list=20key=20drone=20@git=20commands,=20point?= =?UTF-8?q?=20to=20--help,=20show=20disable=20path=20(git=5Fgate.enabled?= =?UTF-8?q?=3Dfalse=20in=20.aipass/hooks.json,=20verified=20engine=20skips?= =?UTF-8?q?=20disabled=20hooks=20per-hook).=20Split=20GIT=5FGH=5FREDIRECT?= =?UTF-8?q?=20->=20GIT=5FREDIRECT=20+=20GH=5FREDIRECT;=20EDIT=5FREDIRECT?= =?UTF-8?q?=20shows=20disable=20too.=20Init=20notice=20in=20project=5Fhook?= =?UTF-8?q?s.json=20template,=20on/off=20in=20README.=206=20tests,=20seedg?= =?UTF-8?q?o=2031/31.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .aipass/project_hooks.json | 2 +- CHANGELOG.md | 12 ++++++ src/aipass/hooks/README.md | 20 +++++++++ .../hooks/apps/handlers/security/git_gate.py | 42 ++++++++++++++----- src/aipass/hooks/tests/test_git_gate.py | 38 +++++++++++++++++ 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/.aipass/project_hooks.json b/.aipass/project_hooks.json index 7743c610..d4672fe4 100644 --- a/.aipass/project_hooks.json +++ b/.aipass/project_hooks.json @@ -1,5 +1,5 @@ { - "_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable ` or edit here.", + "_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable ` or edit here. NOTE: git_gate is enabled by default — it enforces git via drone to prevent state conflicts. To disable for your project, set git_gate.enabled to false below (this won't break other hooks).", "hooks_enabled": true, "UserPromptSubmit": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d628e9e..a4ebbbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,18 @@ PyPI version — not the changelog header. ### Fixed +- **`git_gate` block messages now guide external users instead of dead-ending + (issue #620).** A blocked raw `git`/`gh` command previously just errored. The + block message now explains *why* git is enforced (prevents cross-agent state + conflicts), lists the key `drone @git` commands (commit, smart-sync, sync, pr, + checkout), points to `drone @git --help`, and shows how to disable the gate in + isolation (`git_gate.enabled = false` in `.aipass/hooks.json`) — verified + against the engine, which skips a disabled hook per-hook without affecting other + hooks or `drone @git`. The combined `GIT_GH_REDIRECT` was split into distinct + `GIT_REDIRECT` + `GH_REDIRECT`; `EDIT_REDIRECT` also shows the disable path. An + init notice was added to the `project_hooks.json` template and the on/off story + documented in the hooks README. 6 new tests (86 in `test_git_gate`). + - **Telegram `/create` + `/cancel` are now gated to the base @aipass bot (issue #644).** Every per-branch bot inherited `BaseBot`'s `/create` + `/cancel` and could mint new bots — but Patrick designated the base @aipass bot as the *sole* diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index 4b295cd0..947da811 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -118,6 +118,26 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im | Notification | announce | Announcement tone | | PreCompact | compact, rollover | Memory archival + rollover | +## Git Gate + +The `git_gate` handler (`security/git_gate.py`) enforces git access via drone to prevent state conflicts between agents. It is **enabled by default** in every project created by `aipass init`. + +**What it blocks:** Raw `git` write commands (push, commit, checkout, merge, etc.) and raw `gh` commands (except `gh api`). Read-only git verbs (status, log, diff, show, blame, grep, etc.) are allowed raw. + +**What it protects:** Edits to `.claude/settings.json`, `.claude/hooks/`, and `.git/hooks/` — the enforcement layer itself. + +**Disabling for a project:** Set `git_gate.enabled` to `false` in your project's `.aipass/hooks.json`. This disables git enforcement in isolation — all other hooks (edit_gate, rm_gate, prompt injection, etc.) continue to work normally. No sync, rebase, or PR flows depend on git_gate being active; those are handled independently by `drone @git`. + +```json +"git_gate": { + "enabled": false, + "handler": "aipass.hooks.apps.handlers.security.git_gate.handle", + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit" +} +``` + +**Why it's on by default:** Agents reflexively reach for raw git, which causes state chaos in a multi-agent system. The gate redirects to `drone @git` which enforces access tiers (read-only for most branches, write-only for devpulse). External users who don't need multi-agent git orchestration can safely disable it. + ## Kernel Sandbox (srt/bwrap) The sandbox module (`apps/modules/sandbox.py`) provides the kernel-level filesystem boundary for agent sessions. It wraps Anthropic's `@anthropic-ai/sandbox-runtime` (srt) library, which uses bubblewrap (bwrap) + Landlock + seccomp on Linux to enforce write/read restrictions at the OS level. diff --git a/src/aipass/hooks/apps/handlers/security/git_gate.py b/src/aipass/hooks/apps/handlers/security/git_gate.py index f7bfb000..7940743b 100644 --- a/src/aipass/hooks/apps/handlers/security/git_gate.py +++ b/src/aipass/hooks/apps/handlers/security/git_gate.py @@ -61,20 +61,42 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo") -GIT_GH_REDIRECT = ( - "Write git commands are blocked. Read-only verbs (status, log, diff, show, etc.) are allowed raw.\n" - "For write operations, use drone:\n" - " drone @git smart-sync # fetch + rebase\n" - " drone @git sync # checkout main + pull\n" - " drone @git issue list # GitHub issues\n" +GIT_REDIRECT = ( + "AIPass enforces git via drone to prevent state conflicts between agents.\n" + "Read-only verbs (status, log, diff, show, blame, grep, etc.) are allowed raw.\n" + "\n" + "For write operations, use drone @git:\n" + " drone @git commit [--all | files] # commit changes\n" + " drone @git smart-sync # fetch + rebase\n" + " drone @git sync # checkout main + pull\n" + " drone @git pr # push + create PR\n" + " drone @git checkout # switch branches\n" + "\n" + "Run `drone @git --help` for the full command list.\n" + "To disable this gate for your project: set git_gate.enabled to false\n" + "in your .aipass/hooks.json (this won't break other AIPass hooks)." +) + +GH_REDIRECT = ( + "AIPass enforces gh via drone to prevent state conflicts between agents.\n" + "Only `gh api` is allowed raw.\n" + "\n" + "For GitHub operations, use drone @git:\n" + " drone @git issue list # list issues\n" " drone @git run list # CI runs\n" - " drone @git workflow run # trigger workflows" + " drone @git workflow run # trigger workflows\n" + " drone @git pr # push + create PR\n" + "\n" + "Run `drone @git --help` for the full command list.\n" + "To disable this gate for your project: set git_gate.enabled to false\n" + "in your .aipass/hooks.json (this won't break other AIPass hooks)." ) EDIT_REDIRECT = ( "{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ " "govern the enforcement layer itself.\n" - "If a real change is needed, ask devpulse to make it directly." + "If a real change is needed, ask devpulse to make it directly.\n" + "To disable this protection: set git_gate.enabled to false in .aipass/hooks.json." ) _BLOCK_ALLOW = {"stdout": "", "exit_code": 0} @@ -142,9 +164,9 @@ def _check_bash(tool_input: dict) -> dict: scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd) scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan) if RAW_GIT_RE.search(scan) and not _all_git_reads(scan): - return _block(GIT_GH_REDIRECT) + return _block(GIT_REDIRECT) if RAW_GH_RE.search(scan) and not _is_allowed_gh(cmd): - return _block(GIT_GH_REDIRECT) + return _block(GH_REDIRECT) return _BLOCK_ALLOW diff --git a/src/aipass/hooks/tests/test_git_gate.py b/src/aipass/hooks/tests/test_git_gate.py index 9271beae..f6640ef0 100644 --- a/src/aipass/hooks/tests/test_git_gate.py +++ b/src/aipass/hooks/tests/test_git_gate.py @@ -336,3 +336,41 @@ class TestGitGateMisc: result = _bash("git push") parsed = json.loads(result["stdout"]) assert "Read-only verbs" in parsed["reason"] + + def test_git_block_explains_why(self): + result = _bash("git push") + parsed = json.loads(result["stdout"]) + assert "enforces git via drone" in parsed["reason"] + + def test_git_block_lists_drone_commands(self): + result = _bash("git commit -m 'msg'") + parsed = json.loads(result["stdout"]) + assert "drone @git commit" in parsed["reason"] + assert "drone @git smart-sync" in parsed["reason"] + assert "drone @git --help" in parsed["reason"] + + def test_git_block_shows_disable_path(self): + result = _bash("git push") + parsed = json.loads(result["stdout"]) + assert "git_gate.enabled" in parsed["reason"] + assert "hooks.json" in parsed["reason"] + + def test_gh_block_separate_message(self): + result = _bash("gh pr list") + parsed = json.loads(result["stdout"]) + assert "enforces gh via drone" in parsed["reason"] + assert "gh api" in parsed["reason"] + assert "drone @git issue" in parsed["reason"] + + def test_edit_block_shows_disable_path(self): + from aipass.hooks.apps.handlers.security.git_gate import handle + + result = handle( + { + "tool_name": "Edit", + "tool_input": {"file_path": "/home/patrick/.claude/settings.json"}, + "cwd": CWD, + } + ) + parsed = json.loads(result["stdout"]) + assert "git_gate.enabled" in parsed["reason"]