brindle 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {brindle-0.3.1 → brindle-0.3.2}/PKG-INFO +18 -2
- {brindle-0.3.1 → brindle-0.3.2}/README.md +17 -1
- {brindle-0.3.1 → brindle-0.3.2}/pyproject.toml +1 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/agents.py +6 -3
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/antigravity.py +111 -20
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/autopilot.py +4 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/cli.py +8 -4
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/config.py +6 -2
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/permissions.py +183 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/account.py +6 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/auth.py +1 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/providers.py +31 -1
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/repos.py +5 -1
- {brindle-0.3.1 → brindle-0.3.2}/.gitignore +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/LICENSE +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/SCHEDULE-A +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/__init__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/__main__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/account.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/airgap.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/budget.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/codemap.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/company_login.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/cost.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/cull.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/db.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/demo.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/detect.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/doctor.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/escapes.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/events.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/gates.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/git.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/history.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/inbox.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/keystore.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/learning.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/legacy.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/mcp_server.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/model_access.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/client.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pipeline.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/plugins.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/policy.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pool.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pricing.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/procs.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/profiles.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/quota.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/rewind.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/savings.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/scratch.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/secrets.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/services.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/sessions.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/tasks.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/tmux.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/usage.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/view.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/watch.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.1 → brindle-0.3.2}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
89
89
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
90
90
|
|
|
91
91
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
92
|
-
session shows it too (`brindle 0.3.
|
|
92
|
+
session shows it too (`brindle 0.3.2`). A session started before an upgrade keeps
|
|
93
93
|
running the old code, and shows the old number, until you restart it.
|
|
94
94
|
|
|
95
95
|
## Quick start
|
|
@@ -539,6 +539,7 @@ Autopilot, merge gates and cleanup:
|
|
|
539
539
|
| `merge_into` | none | branch that worker branches are cut from and merge into, whatever branch the supervisor is on |
|
|
540
540
|
| `auto_merge_default_branch` | `false` | let the pipeline merge into the repo's default branch (origin HEAD, else `main`/`master`) on its own; by default it sends a "needs you" message instead, and you run `merge_workspace` yourself (manual merges are never gated) |
|
|
541
541
|
| `permission_policy` | `"off"` | `"on"`: brindle answers Claude Code workers' permission prompts from its rules (see "Permission policy" below); off, the prompts behave as they always did |
|
|
542
|
+
| `agy_approvals` | `"prompt"` | `"brindle"`: with the policy on, Antigravity workers run without approval prompts and brindle's hook allows or denies every call (see "Antigravity" under "Permission policy") |
|
|
542
543
|
| `rules` | `[]` | standing rules for the supervisor, e.g. `["Fix review findings without asking", "Validate options before offering them"]`; added to every supervisor's brief. Rules in `~/.brindle/config.json`, the repo's config and `config.local.json` are all kept, so a team's rules and your own add up |
|
|
543
544
|
| `plan_first` | `false` | workers propose a plan (`submit_plan`) and wait for `approve_plan` before editing |
|
|
544
545
|
| `overlap` | `"block"` | a task whose `files` overlap a running task's is refused (`"warn"` starts it with a warning) |
|
|
@@ -726,6 +727,21 @@ does it by hand. To undo, turn `permission_policy` off and run `sync-agy`: brind
|
|
|
726
727
|
removes exactly its own entries. brindle doesn't learn from agy approvals (its
|
|
727
728
|
hook runs for every tool call, so a tool running doesn't mean you approved it).
|
|
728
729
|
|
|
730
|
+
With `"agy_approvals": "brindle"` (and the policy on), brindle's hook is the
|
|
731
|
+
only gate instead: agy workers start with `--dangerously-skip-permissions`,
|
|
732
|
+
under which agy still honors a hook's deny, and the hook runs on every tool,
|
|
733
|
+
subagents' calls included. It allows what a rule allows (a repo's checks
|
|
734
|
+
included), the shell commands in the profile's `allowed_tools` (what a Claude
|
|
735
|
+
Code worker on it runs without asking), files inside the workspace given as one
|
|
736
|
+
absolute path (writes under `.agents/`, `.git/` and
|
|
737
|
+
`.brindle/` only by a rule, since those hold the hook, git's hooks and the
|
|
738
|
+
repo's checks), brindle's own tools and agy's tools that act only on the
|
|
739
|
+
conversation, and denies everything else, telling the agent to
|
|
740
|
+
ask its supervisor; you allow it with `brindle permissions allow`. Any failure
|
|
741
|
+
in the hook is a deny, and a tool brindle doesn't know is denied. Nobody can
|
|
742
|
+
approve a prompt in the agent's pane in this mode. The default, `"prompt"`,
|
|
743
|
+
leaves agy asking as described above.
|
|
744
|
+
|
|
729
745
|
## brindle Pro and Team
|
|
730
746
|
|
|
731
747
|
brindle is complete on its own. brindle Pro adds hosted learning (which profile
|
|
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
59
59
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
60
60
|
|
|
61
61
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
62
|
-
session shows it too (`brindle 0.3.
|
|
62
|
+
session shows it too (`brindle 0.3.2`). A session started before an upgrade keeps
|
|
63
63
|
running the old code, and shows the old number, until you restart it.
|
|
64
64
|
|
|
65
65
|
## Quick start
|
|
@@ -509,6 +509,7 @@ Autopilot, merge gates and cleanup:
|
|
|
509
509
|
| `merge_into` | none | branch that worker branches are cut from and merge into, whatever branch the supervisor is on |
|
|
510
510
|
| `auto_merge_default_branch` | `false` | let the pipeline merge into the repo's default branch (origin HEAD, else `main`/`master`) on its own; by default it sends a "needs you" message instead, and you run `merge_workspace` yourself (manual merges are never gated) |
|
|
511
511
|
| `permission_policy` | `"off"` | `"on"`: brindle answers Claude Code workers' permission prompts from its rules (see "Permission policy" below); off, the prompts behave as they always did |
|
|
512
|
+
| `agy_approvals` | `"prompt"` | `"brindle"`: with the policy on, Antigravity workers run without approval prompts and brindle's hook allows or denies every call (see "Antigravity" under "Permission policy") |
|
|
512
513
|
| `rules` | `[]` | standing rules for the supervisor, e.g. `["Fix review findings without asking", "Validate options before offering them"]`; added to every supervisor's brief. Rules in `~/.brindle/config.json`, the repo's config and `config.local.json` are all kept, so a team's rules and your own add up |
|
|
513
514
|
| `plan_first` | `false` | workers propose a plan (`submit_plan`) and wait for `approve_plan` before editing |
|
|
514
515
|
| `overlap` | `"block"` | a task whose `files` overlap a running task's is refused (`"warn"` starts it with a warning) |
|
|
@@ -696,6 +697,21 @@ does it by hand. To undo, turn `permission_policy` off and run `sync-agy`: brind
|
|
|
696
697
|
removes exactly its own entries. brindle doesn't learn from agy approvals (its
|
|
697
698
|
hook runs for every tool call, so a tool running doesn't mean you approved it).
|
|
698
699
|
|
|
700
|
+
With `"agy_approvals": "brindle"` (and the policy on), brindle's hook is the
|
|
701
|
+
only gate instead: agy workers start with `--dangerously-skip-permissions`,
|
|
702
|
+
under which agy still honors a hook's deny, and the hook runs on every tool,
|
|
703
|
+
subagents' calls included. It allows what a rule allows (a repo's checks
|
|
704
|
+
included), the shell commands in the profile's `allowed_tools` (what a Claude
|
|
705
|
+
Code worker on it runs without asking), files inside the workspace given as one
|
|
706
|
+
absolute path (writes under `.agents/`, `.git/` and
|
|
707
|
+
`.brindle/` only by a rule, since those hold the hook, git's hooks and the
|
|
708
|
+
repo's checks), brindle's own tools and agy's tools that act only on the
|
|
709
|
+
conversation, and denies everything else, telling the agent to
|
|
710
|
+
ask its supervisor; you allow it with `brindle permissions allow`. Any failure
|
|
711
|
+
in the hook is a deny, and a tool brindle doesn't know is denied. Nobody can
|
|
712
|
+
approve a prompt in the agent's pane in this mode. The default, `"prompt"`,
|
|
713
|
+
leaves agy asking as described above.
|
|
714
|
+
|
|
699
715
|
## brindle Pro and Team
|
|
700
716
|
|
|
701
717
|
brindle is complete on its own. brindle Pro adds hosted learning (which profile
|
|
@@ -1114,7 +1114,7 @@ def format_message(db: DB, body: str, sender_id: str | None) -> str:
|
|
|
1114
1114
|
return body
|
|
1115
1115
|
sender = db.get_agent(sender_id)
|
|
1116
1116
|
who = f"{sender.profile} agent {sender_id}" if sender else f"agent {sender_id}"
|
|
1117
|
-
return f"
|
|
1117
|
+
return f"<details><summary>Message from {who}</summary>\n\nReply with the brindle send_message tool, to_agent_id={sender_id}\n\n{body}\n</details>"
|
|
1118
1118
|
|
|
1119
1119
|
|
|
1120
1120
|
def message_lead(db: DB, agent: Agent, sender_id: str | None) -> str | None:
|
|
@@ -1161,8 +1161,8 @@ def render_unread(db: DB, messages: list) -> str:
|
|
|
1161
1161
|
if m.sender_id:
|
|
1162
1162
|
parts.append(m.body) # stored through format_message: sender and reply hint
|
|
1163
1163
|
else:
|
|
1164
|
-
parts.append(f"
|
|
1165
|
-
return "\n\n
|
|
1164
|
+
parts.append(f"<details><summary>Message from brindle</summary>\n\n{m.body}\n</details>")
|
|
1165
|
+
return "\n\n".join(parts)
|
|
1166
1166
|
|
|
1167
1167
|
|
|
1168
1168
|
def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None,
|
|
@@ -1203,6 +1203,9 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
|
|
|
1203
1203
|
raise AgentError(f"agent {agent.id} is not running")
|
|
1204
1204
|
provider = get_provider(agent.provider)
|
|
1205
1205
|
text = format_message(db, body, sender_id)
|
|
1206
|
+
if agent.provider == "antigravity":
|
|
1207
|
+
from brindle.antigravity import format_delivered_message
|
|
1208
|
+
text = format_delivered_message(text)
|
|
1206
1209
|
if agent.headless:
|
|
1207
1210
|
# Its runner starts the next turn with this, or the Stop hook hands it
|
|
1208
1211
|
# over if a turn is still running.
|
|
@@ -60,6 +60,10 @@ EVENTS = {
|
|
|
60
60
|
"PostToolUse": "agy-post-tool",
|
|
61
61
|
"PreToolUse": "agy-pre-tool",
|
|
62
62
|
}
|
|
63
|
+
# PreToolUse under agy_approvals "brindle" (agy started with
|
|
64
|
+
# --dangerously-skip-permissions): allow or deny only, and deny on any failure.
|
|
65
|
+
STRICT_EVENT = "agy-pre-tool-strict"
|
|
66
|
+
SKIP_FLAG = "--dangerously-skip-permissions"
|
|
63
67
|
# The tools brindle's policy has an opinion on (brindle.permissions.from_agy);
|
|
64
68
|
# others never reach its hook. The hook must answer something (nothing is a
|
|
65
69
|
# deny), and agy's "ask" prompts unless an Always Allow rule covers the call,
|
|
@@ -84,7 +88,17 @@ WARMUP_END = "Reply with just the word: ready. Your task, if any, comes in the n
|
|
|
84
88
|
|
|
85
89
|
|
|
86
90
|
def warmup(instructions: str | None) -> str:
|
|
87
|
-
|
|
91
|
+
parts = [p for p in (TOOLS_NOTE, instructions) if p]
|
|
92
|
+
body = "\n\n".join(parts)
|
|
93
|
+
if body:
|
|
94
|
+
body = f"<details><summary>brindle instructions</summary>\n\n{body}\n\n</details>"
|
|
95
|
+
return f"{body}\n\n{WARMUP_END}" if body else WARMUP_END
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_delivered_message(body: str) -> str:
|
|
99
|
+
if not body:
|
|
100
|
+
return body
|
|
101
|
+
return f"<details><summary>brindle instructions</summary>\n\n{body}\n\n</details>"
|
|
88
102
|
|
|
89
103
|
|
|
90
104
|
class AntigravityError(RuntimeError):
|
|
@@ -166,9 +180,10 @@ def _hook_command(event: str) -> str:
|
|
|
166
180
|
return assigns + " ".join(shlex.quote(a) for a in [*brindle_invocation(), "_hook", event])
|
|
167
181
|
|
|
168
182
|
|
|
169
|
-
def install(workspace: str, permission_policy: bool = False) -> None:
|
|
183
|
+
def install(workspace: str, permission_policy: bool = False, strict: bool = False) -> None:
|
|
170
184
|
"""Give the checkout brindle's MCP server, hooks and rule for agy (and,
|
|
171
|
-
with ``permission_policy``, the PreToolUse hook that applies it
|
|
185
|
+
with ``permission_policy``, the PreToolUse hook that applies it; with
|
|
186
|
+
``strict``, the one for agy_approvals "brindle", on every tool)."""
|
|
172
187
|
from brindle.providers import brindle_invocation
|
|
173
188
|
|
|
174
189
|
cmd = brindle_invocation()
|
|
@@ -180,7 +195,11 @@ def install(workspace: str, permission_policy: bool = False) -> None:
|
|
|
180
195
|
handler = lambda ev: [{"type": "command", "command": _hook_command(EVENTS[ev]), "timeout": 60}] # noqa: E731
|
|
181
196
|
hooks = {"PreInvocation": handler("PreInvocation"), "Stop": handler("Stop"),
|
|
182
197
|
"PostToolUse": [{"matcher": "*", "hooks": handler("PostToolUse")}]}
|
|
183
|
-
if
|
|
198
|
+
if strict:
|
|
199
|
+
# agy runs every call this hook doesn't deny, so it sees every tool.
|
|
200
|
+
hooks["PreToolUse"] = [{"matcher": "*", "hooks": [
|
|
201
|
+
{"type": "command", "command": _hook_command(STRICT_EVENT), "timeout": 60}]}]
|
|
202
|
+
elif permission_policy:
|
|
184
203
|
hooks["PreToolUse"] = [{"matcher": PRE_TOOL_MATCHER, "hooks": handler("PreToolUse")}]
|
|
185
204
|
_merge_json(workspace, AGENTS_DIR / "hooks.json", "", "brindle", hooks)
|
|
186
205
|
rule = Path(workspace) / AGENTS_DIR / "rules" / "brindle.md"
|
|
@@ -298,56 +317,116 @@ def _flush_soon(agent_id: str, delay: float = 1.5) -> None:
|
|
|
298
317
|
start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
299
318
|
|
|
300
319
|
|
|
301
|
-
def pre_tool_decision(db, agent_id: str | None, payload: object) -> dict:
|
|
302
|
-
"""agy's PreToolUse: brindle's permission policy as deny or ask
|
|
320
|
+
def pre_tool_decision(db, agent_id: str | None, payload: object, strict: bool = False) -> dict:
|
|
321
|
+
"""agy's PreToolUse: brindle's permission policy as deny or ask (with
|
|
322
|
+
``strict``, agy_approvals "brindle": allow or deny; see
|
|
323
|
+
permissions.agy_strict_output)."""
|
|
303
324
|
from brindle import permissions
|
|
304
325
|
from brindle.agents import _record_permission
|
|
305
326
|
from brindle.config import load_repo_config
|
|
306
327
|
from brindle.profiles import profile_rules_for
|
|
307
328
|
|
|
329
|
+
output = permissions.agy_strict_output if strict else permissions.agy_output
|
|
308
330
|
agent = db.get_agent(agent_id) if agent_id else None
|
|
309
331
|
ws = db.get_workspace(agent.workspace_id) if agent else None
|
|
310
332
|
if ws is None or not isinstance(payload, dict):
|
|
311
|
-
return
|
|
333
|
+
return output(None)
|
|
312
334
|
cfg = load_repo_config(ws.repo_root)
|
|
313
|
-
if cfg.permission_policy != "on":
|
|
314
|
-
return
|
|
335
|
+
if cfg.permission_policy != "on" and not strict:
|
|
336
|
+
return output(None)
|
|
315
337
|
req = permissions.from_agy(payload, worktree=ws.path, repo_root=ws.repo_root)
|
|
316
338
|
rules = [*permissions.all_rules(ws.repo_root), *profile_rules_for(agent.profile, ws.repo_root)]
|
|
339
|
+
if strict:
|
|
340
|
+
# What a Claude Code worker on this profile runs without asking
|
|
341
|
+
# (its allowed_tools); a deny still wins, since decide() checks those first.
|
|
342
|
+
from brindle.profiles import load_profile
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
rules += permissions.profile_bash_allows(load_profile(agent.profile, ws.repo_root))
|
|
346
|
+
except KeyError:
|
|
347
|
+
pass
|
|
317
348
|
decision = permissions.decide(req, checks=cfg.checks, rules=rules)
|
|
318
|
-
if req is not None and decision.decision == "deny":
|
|
319
|
-
# Only denies are worth a history row here: agy runs this for every
|
|
320
|
-
# tool call, and brindle's allow is only advisory (agy decides).
|
|
321
|
-
_record_permission(db, agent, ws, req.summary(), f"deny: {decision.reason}")
|
|
322
349
|
spaces = payload.get("workspacePaths")
|
|
323
350
|
spaces = [s for s in spaces if isinstance(s, str) and s] if isinstance(spaces, list) else []
|
|
324
|
-
|
|
351
|
+
if strict:
|
|
352
|
+
# Only what brindle knows, not the payload's word: the worktree, and
|
|
353
|
+
# agy's folder for this conversation's plans and task lists.
|
|
354
|
+
spaces = [ws.path]
|
|
355
|
+
artifacts = payload.get("artifactDirectoryPath")
|
|
356
|
+
brain = os.path.realpath(Path.home() / ".gemini" / "antigravity-cli" / "brain")
|
|
357
|
+
if isinstance(artifacts, str) and os.path.realpath(artifacts).startswith(brain + os.sep):
|
|
358
|
+
spaces.append(os.path.realpath(artifacts))
|
|
359
|
+
ambiguous = permissions.agy_strict_ambiguity(payload, ws.path) if req is not None else None
|
|
360
|
+
if ambiguous and decision.decision != "deny":
|
|
361
|
+
out = {"decision": "deny", "reason": f"brindle: {ambiguous}, so it can't be checked; "
|
|
362
|
+
"use one absolute path inside your worktree"}
|
|
363
|
+
else:
|
|
364
|
+
out = output(decision, req, spaces, protected_roots=[ws.path, ws.repo_root])
|
|
365
|
+
else:
|
|
366
|
+
out = output(decision, req, spaces)
|
|
367
|
+
if req is not None and out["decision"] == "deny":
|
|
368
|
+
# Only denies are worth a history row here: agy runs this for every
|
|
369
|
+
# tool call.
|
|
370
|
+
_record_permission(db, agent, ws, req.summary(), f"deny: {out['reason']}")
|
|
371
|
+
return out
|
|
325
372
|
|
|
326
373
|
|
|
327
374
|
PRE_TOOL_FALLBACK = json.dumps({"decision": "ask", "reason": "brindle: no decision"})
|
|
375
|
+
STRICT_FALLBACK = json.dumps({"decision": "deny", "reason": "brindle: couldn't check this call, so it's denied"})
|
|
328
376
|
|
|
329
377
|
|
|
330
|
-
def
|
|
378
|
+
def _launched_with_skip_flag(max_depth: int = 4) -> bool | None:
|
|
379
|
+
"""Whether the agy process that ran this hook was started with
|
|
380
|
+
--dangerously-skip-permissions: True, False, or None when that can't be
|
|
381
|
+
told (then the caller assumes it was)."""
|
|
382
|
+
pid: int | None = os.getppid()
|
|
383
|
+
try:
|
|
384
|
+
for _ in range(max_depth):
|
|
385
|
+
if not pid or pid <= 1:
|
|
386
|
+
return None
|
|
387
|
+
argv = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
|
|
388
|
+
capture_output=True, text=True).stdout.split()
|
|
389
|
+
if SKIP_FLAG in argv:
|
|
390
|
+
return True
|
|
391
|
+
if argv and os.path.basename(argv[0]) == "agy":
|
|
392
|
+
return False
|
|
393
|
+
pid = _parent(pid)
|
|
394
|
+
except (OSError, ValueError):
|
|
395
|
+
return None
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def pre_tool_main(stdin_text: str, db_factory=None, strict: bool = False) -> str:
|
|
331
400
|
"""``brindle _hook agy-pre-tool``: always a JSON answer (deny, ask, or allow
|
|
332
401
|
for a file in the workspace); any failure (no agent, bad input, a broken
|
|
333
|
-
DB) is ask, since agy reads no answer as deny.
|
|
402
|
+
DB) is ask, since agy reads no answer as deny.
|
|
403
|
+
|
|
404
|
+
``strict`` (``agy-pre-tool-strict``, agy_approvals "brindle"): agy runs
|
|
405
|
+
whatever isn't denied, so a failure is a deny. The one exception is an agy
|
|
406
|
+
that plainly wasn't started with the skip flag and isn't a brindle agent
|
|
407
|
+
(the person's own session in a checkout brindle set up): that one prompts
|
|
408
|
+
as usual, so it gets ask."""
|
|
409
|
+
fallback = STRICT_FALLBACK if strict else PRE_TOOL_FALLBACK
|
|
334
410
|
try:
|
|
335
411
|
try:
|
|
336
412
|
payload = json.loads(stdin_text) if stdin_text.strip() else {}
|
|
337
413
|
except ValueError:
|
|
338
|
-
payload = None # unreadable:
|
|
414
|
+
payload = None # unreadable: the fallback
|
|
339
415
|
agent_id = os.environ.get("BRINDLE_AGENT_ID") or agent_from_parent()
|
|
416
|
+
if strict and not agent_id:
|
|
417
|
+
return PRE_TOOL_FALLBACK if _launched_with_skip_flag() is False else STRICT_FALLBACK
|
|
340
418
|
if db_factory is None:
|
|
341
419
|
from brindle.db import DB as db_factory
|
|
342
|
-
out = pre_tool_decision(db_factory(), agent_id, payload)
|
|
343
|
-
|
|
420
|
+
out = pre_tool_decision(db_factory(), agent_id, payload, strict=strict)
|
|
421
|
+
allowed = ("deny", "allow") if strict else ("deny", "ask", "allow")
|
|
422
|
+
if isinstance(out, dict) and out.get("decision") in allowed:
|
|
344
423
|
return json.dumps(out)
|
|
345
424
|
except Exception as e: # noqa: BLE001
|
|
346
425
|
try:
|
|
347
426
|
print(f"brindle hook agy-pre-tool: {e}", file=sys.stderr)
|
|
348
427
|
except Exception: # noqa: BLE001
|
|
349
428
|
pass
|
|
350
|
-
return
|
|
429
|
+
return fallback
|
|
351
430
|
|
|
352
431
|
|
|
353
432
|
def hook_main(db, event: str, stdin_text: str) -> str:
|
|
@@ -541,6 +620,18 @@ def policy_on(workspace: str) -> list[str] | None:
|
|
|
541
620
|
return list(cfg.checks or []) if cfg.permission_policy == "on" else None
|
|
542
621
|
|
|
543
622
|
|
|
623
|
+
def strict_on(workspace: str) -> bool:
|
|
624
|
+
"""agy_approvals "brindle" with the permission policy on: agy runs with
|
|
625
|
+
--dangerously-skip-permissions and brindle's hook decides every call."""
|
|
626
|
+
from brindle.config import load_repo_config
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
cfg = load_repo_config(workspace)
|
|
630
|
+
except Exception: # noqa: BLE001
|
|
631
|
+
return False
|
|
632
|
+
return cfg.permission_policy == "on" and cfg.agy_approvals == "brindle"
|
|
633
|
+
|
|
634
|
+
|
|
544
635
|
def sync_for_launch(workspace: str, checks: list[str] | None) -> None:
|
|
545
636
|
"""An agy worker is starting in ``workspace``: bring agy's settings in
|
|
546
637
|
line with the repo's policy (``checks`` is None when it's off). Never
|
|
@@ -145,7 +145,10 @@ Acting on what you find:
|
|
|
145
145
|
verify each finding (a reviewer can be wrong), fix the real ones, and report
|
|
146
146
|
what you fixed and which findings weren't real. Don't ask first. Ask only
|
|
147
147
|
before outward-facing or hard-to-reverse actions (posting, merging into a
|
|
148
|
-
shared branch, deleting) and for decisions that are the user's to make.
|
|
148
|
+
shared branch, deleting) and for decisions that are the user's to make.
|
|
149
|
+
- Format your own verbose outputs or message transcripts in collapsible
|
|
150
|
+
`<details><summary>Title</summary>...</details>` blocks so they are not
|
|
151
|
+
sprawling in the chat UI."""
|
|
149
152
|
|
|
150
153
|
|
|
151
154
|
def supervisor_rules(cfg: RepoConfig) -> str:
|
|
@@ -1164,11 +1164,14 @@ _KIND_HELP = "read, write, edit, bash, fetch, mcp or other."
|
|
|
1164
1164
|
@permissions_app.command("allow")
|
|
1165
1165
|
def permissions_allow(
|
|
1166
1166
|
kind: str = typer.Argument(..., help=_KIND_HELP),
|
|
1167
|
-
match: str = typer.Argument(
|
|
1167
|
+
match: Optional[str] = typer.Argument(None, help="The command, path, URL or tool name (exact unless --prefix/--glob)."),
|
|
1168
1168
|
prefix: bool = typer.Option(False, "--prefix", help="Match anything starting with MATCH."),
|
|
1169
1169
|
glob: bool = typer.Option(False, "--glob", help="MATCH is a shell-style glob (* also crosses /)."),
|
|
1170
1170
|
) -> None:
|
|
1171
1171
|
"""Allow requests that match. A bash allow never covers a command with shell metacharacters."""
|
|
1172
|
+
if match is None:
|
|
1173
|
+
match = kind
|
|
1174
|
+
kind = "bash"
|
|
1172
1175
|
_add_permission_rule("allow", kind, match, prefix, glob)
|
|
1173
1176
|
|
|
1174
1177
|
|
|
@@ -2428,15 +2431,16 @@ def ci_init(
|
|
|
2428
2431
|
@app.command("_hook", hidden=True)
|
|
2429
2432
|
def hook(event: str, agent: Optional[str] = typer.Option(None, "--agent"),
|
|
2430
2433
|
payload: Optional[str] = typer.Argument(None)) -> None:
|
|
2431
|
-
if event
|
|
2432
|
-
# agy reads no answer as deny, so this always prints one (ask on any
|
|
2434
|
+
if event in ("agy-pre-tool", "agy-pre-tool-strict"):
|
|
2435
|
+
# agy reads no answer as deny, so this always prints one (ask on any
|
|
2436
|
+
# failure; deny under agy_approvals "brindle", where ask would run it).
|
|
2433
2437
|
from brindle import antigravity
|
|
2434
2438
|
|
|
2435
2439
|
try:
|
|
2436
2440
|
text = sys.stdin.read()
|
|
2437
2441
|
except Exception: # noqa: BLE001
|
|
2438
2442
|
text = ""
|
|
2439
|
-
typer.echo(antigravity.pre_tool_main(text))
|
|
2443
|
+
typer.echo(antigravity.pre_tool_main(text, strict=event == "agy-pre-tool-strict"))
|
|
2440
2444
|
return
|
|
2441
2445
|
if event.startswith("agy-"):
|
|
2442
2446
|
from brindle import antigravity
|
|
@@ -74,7 +74,7 @@ class RepoConfig:
|
|
|
74
74
|
# the pipeline merges only when both approve. Empty: one review (see brindle.pipeline).
|
|
75
75
|
second_review: dict[str, str] = field(default_factory=dict)
|
|
76
76
|
pre_commit: bool = True # run pre-commit (the framework) over a branch before merging
|
|
77
|
-
max_agents: int =
|
|
77
|
+
max_agents: int = 5 # workers running at once per session; 0 means no cap
|
|
78
78
|
check_timeout: int = 900 # seconds allowed for each check command
|
|
79
79
|
check_concurrency: int = 2 # check runs at once on this machine, across branches; the rest queue (0: no cap)
|
|
80
80
|
usage_limit: int = 90 # autopilot stops pushing on at this % of the Claude usage limit
|
|
@@ -95,6 +95,10 @@ class RepoConfig:
|
|
|
95
95
|
# "on": brindle answers workers' permission requests by its rules (see
|
|
96
96
|
# brindle.permissions). Unset, it's "on" for Pro and up and "off" on Free.
|
|
97
97
|
permission_policy: str = "off"
|
|
98
|
+
# "brindle": with the policy on, agy workers run with
|
|
99
|
+
# --dangerously-skip-permissions and brindle's hook allows or denies every
|
|
100
|
+
# call (see brindle.antigravity). "prompt" (default): agy asks as usual.
|
|
101
|
+
agy_approvals: str = "prompt"
|
|
98
102
|
overlap: str = "block" # a task whose files overlap a running one: "block" or "warn"
|
|
99
103
|
# Paths/globs whose merge conflicts brindle never hands to a worker to
|
|
100
104
|
# resolve (migrations, lockfiles, generated code): they go to the person.
|
|
@@ -209,7 +213,7 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
209
213
|
"usage_limit", "pool_size", "graphify", "stale_after", "pipeline",
|
|
210
214
|
"review_rounds", "fable_escalation", "goal_audit", "overlap", "local_models", "merge_into",
|
|
211
215
|
"auto_merge_default_branch", "delete_merged_branches", "delegation", "sidebar", "pr_footer", "plan_first", "learning",
|
|
212
|
-
"learning_candidates", "limit_cooldown_minutes", "message_delivery", "permission_policy",
|
|
216
|
+
"learning_candidates", "limit_cooldown_minutes", "message_delivery", "permission_policy", "agy_approvals",
|
|
213
217
|
"learned_rules_repeats", "learned_rules_profile"):
|
|
214
218
|
for source in (local, shared, user):
|
|
215
219
|
if key in source:
|
|
@@ -169,6 +169,20 @@ def _default(id_: str, kind: str, match: str, match_type: str, decision: str) ->
|
|
|
169
169
|
return Rule(kind, match, match_type, decision, "default", 0.0, id_)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def _preset(kind: str, match: str, match_type: str, decision: str) -> Rule:
|
|
173
|
+
return Rule(kind, match, match_type, decision, "user", time.time(), "")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
PRESET_COMMANDS = ("aws", "uv", "pytest", "python", "npm", "pnpm", "yarn", "docker", "cargo", "go", "make", "gh")
|
|
177
|
+
PRESET_RULES = tuple(
|
|
178
|
+
rule
|
|
179
|
+
for cmd in PRESET_COMMANDS
|
|
180
|
+
for rule in (
|
|
181
|
+
_preset("bash", cmd, "exact", "allow"),
|
|
182
|
+
_preset("bash", f"{cmd} ", "prefix", "allow"),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
172
186
|
DEFAULT_RULES: tuple[Rule, ...] = (
|
|
173
187
|
_default("d-tracked", "read", "a file git tracks in the worktree or repo root", "tracked", "allow"),
|
|
174
188
|
_default("d-checks", "bash", "exactly one of the repo's `checks` commands", "check", "allow"),
|
|
@@ -415,6 +429,144 @@ def agy_output(decision: Decision | None, req: Request | None = None,
|
|
|
415
429
|
return {"decision": "ask", "reason": f"brindle: {why}"}
|
|
416
430
|
|
|
417
431
|
|
|
432
|
+
# agy's own tools that act only on the conversation (tasks, messages to its
|
|
433
|
+
# subagents, questions to the person); a subagent's tool calls reach the hook
|
|
434
|
+
# themselves (checked on agy 1.3.2). Anything not listed here or mapped in
|
|
435
|
+
# AGY_KINDS is denied under agy_approvals "brindle", so a tool agy adds later
|
|
436
|
+
# starts out blocked.
|
|
437
|
+
AGY_CONVERSATION_TOOLS = frozenset({"manage_task", "send_message", "ask_question", "invoke_subagent"})
|
|
438
|
+
# Inside a workspace, but not files the agent may write without a rule: what
|
|
439
|
+
# brindle's hook and agy's settings are read from (.agents), what git runs
|
|
440
|
+
# (.git/hooks, config), and brindle's repo config (checks, rules).
|
|
441
|
+
AGY_PROTECTED_DIRS = (".agents", ".git", ".brindle")
|
|
442
|
+
NEEDS_APPROVAL = ("brindle runs this agent without approval prompts, and no rule allows this "
|
|
443
|
+
"({why}). Don't try another way to do the same thing: tell your supervisor "
|
|
444
|
+
"with send_message what you need and why, so the person can allow it with "
|
|
445
|
+
"`brindle permissions allow`.")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _lexically_within(literal: str, root: str) -> bool:
|
|
449
|
+
"""``literal`` is ``root`` or under it as spelled, taking ``root`` as
|
|
450
|
+
given or resolved (macOS's /tmp is /private/tmp), with nothing resolved
|
|
451
|
+
in ``literal`` itself."""
|
|
452
|
+
for r in {os.path.normpath(root), os.path.realpath(root)}:
|
|
453
|
+
if literal == r or literal.startswith(r.rstrip(os.sep) + os.sep):
|
|
454
|
+
return True
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _through_symlink(literal: str, roots: list[str]) -> bool:
|
|
459
|
+
"""Whether ``literal`` (absolute, normalized) passes through a symlink
|
|
460
|
+
below one of ``roots``, the file itself included. A root's own symlinks
|
|
461
|
+
(macOS's /tmp -> /private/tmp) don't count."""
|
|
462
|
+
for root in {f(r) for r in roots if r for f in (os.path.normpath, os.path.realpath)}:
|
|
463
|
+
rel = os.path.relpath(literal, root)
|
|
464
|
+
if rel == ".." or rel.startswith(".." + os.sep) or rel == ".":
|
|
465
|
+
continue
|
|
466
|
+
prefix = root
|
|
467
|
+
for part in rel.split(os.sep):
|
|
468
|
+
prefix = os.path.join(prefix, part)
|
|
469
|
+
if os.path.islink(prefix):
|
|
470
|
+
return True
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _agy_protected(literal: str, real: str, roots: list[str]) -> bool:
|
|
475
|
+
"""Whether a write lands in a .agents/.git/.brindle folder under one of
|
|
476
|
+
``roots``, at any depth, ignoring case (macOS and Windows file systems
|
|
477
|
+
do). Checked on the path as written and as resolved, and against each
|
|
478
|
+
top-level protected folder resolved, so a symlink either way (a
|
|
479
|
+
``.agents`` that points elsewhere, or a path that points into one) is
|
|
480
|
+
still caught."""
|
|
481
|
+
names = {d.casefold() for d in AGY_PROTECTED_DIRS}
|
|
482
|
+
if _through_symlink(literal, roots):
|
|
483
|
+
return True # where it really lands is not worth guessing at
|
|
484
|
+
for root in (r for r in roots if r):
|
|
485
|
+
for p, r in ((literal, os.path.normpath(root)), (real, os.path.realpath(root))):
|
|
486
|
+
rel = os.path.relpath(p, r)
|
|
487
|
+
if rel != ".." and not rel.startswith(".." + os.sep):
|
|
488
|
+
if names & {part.casefold() for part in rel.split(os.sep)}:
|
|
489
|
+
return True
|
|
490
|
+
for d in AGY_PROTECTED_DIRS:
|
|
491
|
+
target = os.path.realpath(os.path.join(root, d))
|
|
492
|
+
if real == target or real.startswith(target + os.sep):
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _brindle_tools() -> set[str]:
|
|
498
|
+
"""brindle's MCP tools by both names agy gives them: mcp_brindle_<tool>
|
|
499
|
+
and, through call_mcp_tool, mcp__brindle__<tool>. Exact names, so another
|
|
500
|
+
server's tools (say a server named brindle_x) never count."""
|
|
501
|
+
from brindle.antigravity import tool_names
|
|
502
|
+
|
|
503
|
+
names = tool_names()
|
|
504
|
+
return {f"mcp_brindle_{n}" for n in names} | {f"mcp__brindle__{n}" for n in names}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def agy_strict_ambiguity(payload: dict, worktree: str) -> str | None:
|
|
508
|
+
"""Why brindle can't be sure it reads an agy call the way agy will, or
|
|
509
|
+
None. Under agy_approvals "brindle" such a call is denied rather than
|
|
510
|
+
guessed at: a JSON-encoded value (brindle decodes it, agy may not), a
|
|
511
|
+
relative or ~ path (agy may resolve it against something other than
|
|
512
|
+
Cwd), more than one path, or a command run outside the worktree."""
|
|
513
|
+
call = payload.get("toolCall") if isinstance(payload, dict) else None
|
|
514
|
+
args = call.get("args") if isinstance(call, dict) and isinstance(call.get("args"), dict) else {}
|
|
515
|
+
for name in (*AGY_PATH_ARGS, "Cwd", "CommandLine"):
|
|
516
|
+
raw = args.get(name)
|
|
517
|
+
if isinstance(raw, str) and raw and _agy_arg(args, name) != raw:
|
|
518
|
+
return f"{name} is JSON-encoded"
|
|
519
|
+
paths = {args[a] for a in AGY_PATH_ARGS if isinstance(args.get(a), str) and args[a]}
|
|
520
|
+
if len(paths) > 1:
|
|
521
|
+
return "the call names more than one path"
|
|
522
|
+
if any(not os.path.isabs(p) for p in paths):
|
|
523
|
+
return "the call names a relative path"
|
|
524
|
+
if any(".." in p.split("/") or ".." in p.split(os.sep) for p in paths):
|
|
525
|
+
return "the path has a .. in it" # resolved after a symlink, not as written
|
|
526
|
+
cwd = args.get("Cwd")
|
|
527
|
+
if call.get("name") == "run_command" and isinstance(cwd, str) and cwd:
|
|
528
|
+
real, root = os.path.realpath(cwd), os.path.realpath(worktree)
|
|
529
|
+
if not os.path.isabs(cwd) or (real != root and not _inside(real, worktree)):
|
|
530
|
+
return "the command runs outside the worktree"
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def agy_strict_output(decision: Decision | None, req: Request | None = None,
|
|
535
|
+
workspaces: list[str] | None = None,
|
|
536
|
+
protected_roots: list[str] | None = None) -> dict:
|
|
537
|
+
"""agy's PreToolUse answer under ``agy_approvals: "brindle"``, where agy
|
|
538
|
+
runs with --dangerously-skip-permissions: a hook's "ask" then runs the
|
|
539
|
+
call without a prompt, so brindle answers allow or deny, never ask.
|
|
540
|
+
Allowed: what a rule allows, a file inside a workspace (or the
|
|
541
|
+
conversation's artifact folder, passed in ``workspaces``), brindle's own
|
|
542
|
+
tools, and agy's conversation-only tools. Everything else is denied with
|
|
543
|
+
a reason that tells the agent to ask its supervisor."""
|
|
544
|
+
if decision is not None and decision.decision == "deny":
|
|
545
|
+
return {"decision": "deny", "reason": f"brindle: {decision.reason}"}
|
|
546
|
+
if decision is not None and decision.decision == "allow":
|
|
547
|
+
return {"decision": "allow", "reason": f"brindle: {decision.reason}"}
|
|
548
|
+
if req is not None:
|
|
549
|
+
path = os.path.realpath(req.path) if req.kind in PATH_KINDS and req.path else None
|
|
550
|
+
# Protected wherever the workspaces list says the workspace is: the
|
|
551
|
+
# anchors are brindle's own worktree and repo root.
|
|
552
|
+
literal = os.path.normpath(req.path) if path else None
|
|
553
|
+
protected = (path and req.kind != "read"
|
|
554
|
+
and _agy_protected(literal, path, protected_roots or []))
|
|
555
|
+
# In the workspace as written, not only once resolved: a path that
|
|
556
|
+
# gets there through a symlink outside it would skip the symlink walk.
|
|
557
|
+
if path and not protected and any(
|
|
558
|
+
w and _lexically_within(literal, w) and (path == os.path.realpath(w) or _inside(path, w))
|
|
559
|
+
for w in workspaces or []):
|
|
560
|
+
return {"decision": "allow", "reason": "brindle: a file in the workspace"}
|
|
561
|
+
if req.kind == "mcp" and req.tool in _brindle_tools():
|
|
562
|
+
return {"decision": "allow", "reason": "brindle: brindle's own tool"}
|
|
563
|
+
if req.kind == "other" and req.tool in AGY_CONVERSATION_TOOLS:
|
|
564
|
+
return {"decision": "allow", "reason": "brindle: acts only on the conversation"}
|
|
565
|
+
why = decision.reason if decision is not None else "brindle couldn't read the request"
|
|
566
|
+
why = why.removesuffix("; asking the user") # nobody is asked here
|
|
567
|
+
return {"decision": "deny", "reason": "brindle: " + NEEDS_APPROVAL.format(why=why)}
|
|
568
|
+
|
|
569
|
+
|
|
418
570
|
# -- matching ---------------------------------------------------------------------------------
|
|
419
571
|
|
|
420
572
|
|
|
@@ -592,6 +744,27 @@ def profile_rules(profile) -> list[Rule]:
|
|
|
592
744
|
return out
|
|
593
745
|
|
|
594
746
|
|
|
747
|
+
PROFILE_BASH = re.compile(r"Bash\(([^()*]+?)(:\*)?\)")
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def profile_bash_allows(profile) -> list[Rule]:
|
|
751
|
+
"""The profile's ``allowed_tools`` shell entries as allow rules, for an
|
|
752
|
+
agy worker under agy_approvals "brindle", which Claude Code's
|
|
753
|
+
``--allowedTools`` never reaches: ``Bash(git status)`` is that exact
|
|
754
|
+
command, ``Bash(uv run:*)`` it alone or followed by arguments. Like
|
|
755
|
+
every bash allow, a compound or quoted command never matches."""
|
|
756
|
+
out = []
|
|
757
|
+
for entry in getattr(profile, "allowed_tools", None) or []:
|
|
758
|
+
m = PROFILE_BASH.fullmatch(entry.strip())
|
|
759
|
+
if not m:
|
|
760
|
+
continue
|
|
761
|
+
command = m.group(1).strip()
|
|
762
|
+
out.append(Rule("bash", command, "exact", "allow", "profile"))
|
|
763
|
+
if m.group(2):
|
|
764
|
+
out.append(Rule("bash", command + " ", "prefix", "allow", "profile"))
|
|
765
|
+
return out
|
|
766
|
+
|
|
767
|
+
|
|
595
768
|
def invalid_profile_rules(profile) -> list[str]:
|
|
596
769
|
"""Profile deny specs ignored by the policy because they are malformed."""
|
|
597
770
|
invalid = list(getattr(profile, "permission_denies_errors", []))
|
|
@@ -674,8 +847,17 @@ class Store:
|
|
|
674
847
|
|
|
675
848
|
|
|
676
849
|
def load_store() -> Store:
|
|
850
|
+
path = store_path()
|
|
851
|
+
if not path.exists():
|
|
852
|
+
store = Store(rules=list(PRESET_RULES))
|
|
853
|
+
try:
|
|
854
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
855
|
+
path.write_text(store.to_json(), encoding="utf-8")
|
|
856
|
+
except OSError:
|
|
857
|
+
pass
|
|
858
|
+
return store
|
|
677
859
|
try:
|
|
678
|
-
data = json.loads(
|
|
860
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
679
861
|
except (OSError, ValueError):
|
|
680
862
|
return Store()
|
|
681
863
|
if not isinstance(data, dict):
|
|
@@ -661,7 +661,12 @@ class ProAccount(_OrgCommands):
|
|
|
661
661
|
client = auth.Client(base, transport=self.transport)
|
|
662
662
|
browser = not device and loopback.can_open_browser(self.out)
|
|
663
663
|
ent = auth.login(client, self.store, show=self._say, browser=browser)
|
|
664
|
-
|
|
664
|
+
try:
|
|
665
|
+
who = auth.me(client, self.store)
|
|
666
|
+
except auth.AuthError:
|
|
667
|
+
who = {}
|
|
668
|
+
display_name = who.get("name") or who.get("email") or ent.sub
|
|
669
|
+
self._say(f"Logged in as {display_name} ({ent.org_id}), plan {ent.plan}.")
|
|
665
670
|
self._say("See what your plan includes: `brindle account`"
|
|
666
671
|
+ ("" if ent.features else "; get brindle Pro: `brindle account upgrade`"))
|
|
667
672
|
self._company_login(client)
|
|
@@ -663,7 +663,7 @@ def me(client: Client, store) -> dict:
|
|
|
663
663
|
status, body = authed(client, store, "GET", "/me")
|
|
664
664
|
if status != 200:
|
|
665
665
|
raise _error(status, body)
|
|
666
|
-
return {k: _sanitize(body[k]) for k in ("sub", "email", "org_id", "plan", "status", "seats")
|
|
666
|
+
return {k: _sanitize(body[k]) for k in ("sub", "email", "name", "first_name", "last_name", "org_id", "plan", "status", "seats")
|
|
667
667
|
if k in body}
|
|
668
668
|
|
|
669
669
|
|
|
@@ -1085,6 +1085,8 @@ class Codex(Provider):
|
|
|
1085
1085
|
argv += codex_hook.launch_flags(ctx.cwd)
|
|
1086
1086
|
if ctx.profile.model:
|
|
1087
1087
|
argv += ["--model", ctx.profile.model]
|
|
1088
|
+
if ctx.profile.effort:
|
|
1089
|
+
argv += ["-c", f"model_reasoning_effort={json.dumps(ctx.profile.effort)}"]
|
|
1088
1090
|
# Codex has no system-prompt flag; lead the first message with the profile.
|
|
1089
1091
|
first = "\n\n".join(p for p in (ctx.profile.prompt, ctx.initial_prompt) if p)
|
|
1090
1092
|
if first:
|
|
@@ -1148,13 +1150,22 @@ class Antigravity(Provider):
|
|
|
1148
1150
|
def command(self, ctx: LaunchContext) -> list[str]:
|
|
1149
1151
|
from brindle import antigravity
|
|
1150
1152
|
|
|
1153
|
+
strict = False
|
|
1151
1154
|
if ctx.cwd:
|
|
1152
1155
|
policy = antigravity.policy_on(ctx.cwd)
|
|
1153
|
-
antigravity.
|
|
1156
|
+
strict = policy is not None and antigravity.strict_on(ctx.cwd)
|
|
1157
|
+
antigravity.install(ctx.cwd, permission_policy=policy is not None, strict=strict)
|
|
1154
1158
|
antigravity.sync_for_launch(ctx.cwd, policy)
|
|
1155
1159
|
argv = [antigravity.binary()]
|
|
1160
|
+
if strict:
|
|
1161
|
+
# agy ignores a hook's "allow" (google-antigravity/antigravity-cli#1053)
|
|
1162
|
+
# but honors its "deny" with this flag, so brindle's hook, installed
|
|
1163
|
+
# above on every tool, is the gate: see antigravity.pre_tool_main.
|
|
1164
|
+
argv.append(antigravity.SKIP_FLAG)
|
|
1156
1165
|
if ctx.profile.model:
|
|
1157
1166
|
argv += ["--model", ctx.profile.model]
|
|
1167
|
+
if ctx.profile.effort:
|
|
1168
|
+
argv += ["--effort", ctx.profile.effort]
|
|
1158
1169
|
if ctx.profile.permission_mode in ("acceptEdits", "accept-edits", "auto"):
|
|
1159
1170
|
# agy has no classifier mode; accepting edits is the closest.
|
|
1160
1171
|
argv += ["--mode", "accept-edits"]
|
|
@@ -1169,6 +1180,25 @@ class Antigravity(Provider):
|
|
|
1169
1180
|
TRUST_DIALOG = re.compile(r"Do you trust the contents of this project\?", re.I)
|
|
1170
1181
|
TRUST_SELECTED = re.compile(r">\s*Yes, I trust this folder")
|
|
1171
1182
|
READY = re.compile(r"\? for shortcuts|esc to cancel")
|
|
1183
|
+
INPUT_LINE = re.compile(r"^\s*>\s*(.*)$", re.M)
|
|
1184
|
+
ANSI_SGR = re.compile(r"\x1b\[([0-9;]*)m")
|
|
1185
|
+
|
|
1186
|
+
@classmethod
|
|
1187
|
+
def _strip_ansi(cls, text: str) -> str:
|
|
1188
|
+
return cls.ANSI_SGR.sub("", text)
|
|
1189
|
+
|
|
1190
|
+
def paste_blocked(self, screen: str, interactive: bool) -> str | None:
|
|
1191
|
+
if not interactive:
|
|
1192
|
+
return None
|
|
1193
|
+
styled_lines = screen.rstrip().splitlines()[-25:]
|
|
1194
|
+
candidates = [ln for ln in styled_lines if self.INPUT_LINE.match(self._strip_ansi(ln))]
|
|
1195
|
+
if not candidates:
|
|
1196
|
+
return None
|
|
1197
|
+
plain_line = self._strip_ansi(candidates[-1])
|
|
1198
|
+
m = self.INPUT_LINE.match(plain_line)
|
|
1199
|
+
if m and m.group(1).strip():
|
|
1200
|
+
return "typing"
|
|
1201
|
+
return None
|
|
1172
1202
|
|
|
1173
1203
|
def after_launch(self, target: str) -> None:
|
|
1174
1204
|
# A new worktree is a folder agy hasn't seen. brindle made it from the
|
|
@@ -23,7 +23,11 @@ from brindle import git, workspaces
|
|
|
23
23
|
from brindle.db import DB, Workspace
|
|
24
24
|
|
|
25
25
|
FEATURE = "multi_repo"
|
|
26
|
-
PRO_MESSAGE =
|
|
26
|
+
PRO_MESSAGE = (
|
|
27
|
+
"Working across multiple repositories is a restricted feature of brindle Pro. "
|
|
28
|
+
"The free tier is limited to a single repository per session. "
|
|
29
|
+
"To upgrade, run `brindle account upgrade` or visit https://pawdelta.com/brindle."
|
|
30
|
+
)
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
class RepoError(RuntimeError):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|