brindle 0.4.6__tar.gz → 0.4.7__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.4.6 → brindle-0.4.7}/PKG-INFO +1 -1
- {brindle-0.4.6 → brindle-0.4.7}/pyproject.toml +1 -1
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/agents.py +143 -24
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codex_hook.py +7 -4
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/config.py +5 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/api-schema.json +46 -6
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/db.py +6 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permission_editor.py +39 -4
- brindle-0.4.7/src/brindle/permission_presets.json +44 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permissions.py +328 -16
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/providers.py +21 -5
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/dto.py +3 -1
- {brindle-0.4.6 → brindle-0.4.7}/.gitignore +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/LICENSE +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/README.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/SCHEDULE-A +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/__main__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/account.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/airgap.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/antigravity.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assembler.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assets/braid_dark.png +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/autopilot.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/budget.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/coordinator.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-opencode.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-sonnet.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-opencode.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/bus.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_client.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_federation.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cli.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/clone.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cloud_login.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codemap.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/company_identity.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/company_login.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/conflicts.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/coordinator.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cost.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cull.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/app.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/auth.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/events.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/grants.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/holds.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/lifecycle.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/peer.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_actions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_org_permissions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_permissions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_read.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/schema.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/session_review.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/terminal.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/debuglog.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/demo.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/detect.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/doctor.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/escapes.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/events.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/gates.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/git.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/guardrails.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/history.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/identity_lock.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/inbox.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/kb.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/key_check.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/keystore.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/knowledge.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/launch_health.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/learned_rules.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/learning.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/legacy.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/local_setup.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/login_pause.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/mcp_server.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/model_access.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/model_availability.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/catalog.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/client.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/loop.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/lsp.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/permissions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/runner.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/serve.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/tools.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/opencode.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/org_permissions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pane_auth.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permission_grants.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/picker.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pipeline.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/plugins.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/policy.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pool.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/preflight.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pricing.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/_files.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/account.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/auth.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/features.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/keys.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/learning.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/license.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/status.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/procs.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/profiles.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/provider_homes.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/providers_install.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/quota.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/repos.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/rewind.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/rule_checks.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/savings.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/scratch.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/secrets.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/self_serve.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/errors.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/inbox.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/routing.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/sessions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/services.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sessions.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sidebar_logo.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/signin_pause.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/status_cache.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/tasks.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/tmux.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/updates.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/upgrade.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/usage.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/view.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/wake.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/watch.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/watch_costs.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/__init__.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/chat.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/dashboard.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/diff.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/server.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/static/app.js +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/static/index.html +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/worker_models.py +0 -0
- {brindle-0.4.6 → brindle-0.4.7}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
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
|
|
@@ -1110,10 +1110,11 @@ def _launch_resolved(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
|
|
|
1110
1110
|
_launch_headless(db, agent, ws, prompt=prompt, resume=resume, watch_pane=watch_pane, m=m)
|
|
1111
1111
|
return
|
|
1112
1112
|
if provider.prompt_after_ready:
|
|
1113
|
-
# Typed in once it's ready, after a warm-up carrying its instructions
|
|
1113
|
+
# Typed in once it's ready, after a warm-up carrying its instructions
|
|
1114
|
+
# on both fresh starts and resumes:
|
|
1114
1115
|
# agy connects its MCP servers only once a conversation has started,
|
|
1115
1116
|
# so the task must arrive as a second turn (see brindle.antigravity).
|
|
1116
|
-
for text in (
|
|
1117
|
+
for text in (provider.warmup(profile), prompt):
|
|
1117
1118
|
if text:
|
|
1118
1119
|
db.enqueue(agent.id, text, None)
|
|
1119
1120
|
prompt = None
|
|
@@ -2031,23 +2032,18 @@ def _deliver_to_inbox(db: DB, agent: Agent, message_id: int, text: str,
|
|
|
2031
2032
|
|
|
2032
2033
|
def flush(db: DB, agent_id: str) -> bool:
|
|
2033
2034
|
"""Deliver the agent's oldest pending message: through its inbox if it
|
|
2034
|
-
has one, else typed into its pane once it's idle
|
|
2035
|
+
has one, else typed into its pane once it's idle (or while busy, for a
|
|
2036
|
+
CLI that holds mid-turn input: see _flush_midturn). A chat with a live
|
|
2035
2037
|
waiter (brindle.wake) is left to it, and one that runs a waiter never
|
|
2036
2038
|
uses the inbox. Returns True if something was delivered."""
|
|
2037
2039
|
from brindle import inbox
|
|
2038
2040
|
from brindle import launch_health
|
|
2039
|
-
from brindle import login_pause
|
|
2040
2041
|
from brindle import wake
|
|
2041
2042
|
|
|
2042
2043
|
agent = db.get_agent(agent_id)
|
|
2043
|
-
if
|
|
2044
|
-
return False
|
|
2045
|
-
if
|
|
2046
|
-
return False # its session waits on an expired cloud login; resuming flushes it
|
|
2047
|
-
waits = wake.enabled(agent)
|
|
2048
|
-
if waits and wake.live(agent):
|
|
2049
|
-
return False # its waiter hands the queue over (brindle.wake)
|
|
2050
|
-
if not waits and inbox.usable(agent):
|
|
2044
|
+
if _delivery_held(db, agent):
|
|
2045
|
+
return False
|
|
2046
|
+
if not wake.enabled(agent) and inbox.usable(agent):
|
|
2051
2047
|
delivered = False
|
|
2052
2048
|
while (msg := db.pop_pending(agent_id)) is not None:
|
|
2053
2049
|
baseline = launch_health.before_delivery(db, agent)
|
|
@@ -2060,11 +2056,48 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
2060
2056
|
break
|
|
2061
2057
|
if delivered:
|
|
2062
2058
|
return True
|
|
2063
|
-
|
|
2059
|
+
# One paste into the pane at a time, whichever path: the idle claim alone
|
|
2060
|
+
# leaves the agent busy for a second sender's mid-turn paste while the
|
|
2061
|
+
# first is still typing, reordering or merging the two.
|
|
2062
|
+
with delivery_lock(agent_id):
|
|
2063
|
+
return _flush_pane(db, agent_id)
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def delivery_lock(agent_id: str):
|
|
2067
|
+
"""Held across processes by whoever types a queued message into
|
|
2068
|
+
``agent_id``'s pane (``flush``), from the look at its state to the end of
|
|
2069
|
+
the paste. A lock file under the brindle home, so a holder that dies gives
|
|
2070
|
+
it back; re-entrant within a thread."""
|
|
2071
|
+
from brindle import pipeline
|
|
2072
|
+
|
|
2073
|
+
return pipeline._reentrant_lock("deliver-" + hashlib.sha1(agent_id.encode()).hexdigest()[:16])
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
def _delivery_held(db: DB, agent: Agent | None) -> bool:
|
|
2077
|
+
"""Whether ``flush`` must leave the queue alone: the agent is gone or
|
|
2078
|
+
headless (its runner takes messages from the inbox itself), its session
|
|
2079
|
+
waits on an expired cloud login (resuming flushes it), or a live waiter
|
|
2080
|
+
hands the queue over (brindle.wake)."""
|
|
2081
|
+
from brindle import login_pause
|
|
2082
|
+
from brindle import wake
|
|
2083
|
+
|
|
2084
|
+
return (agent is None or agent.headless or login_pause.holding(db, agent.id)
|
|
2085
|
+
or (wake.enabled(agent) and wake.live(agent)))
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def _flush_pane(db: DB, agent_id: str) -> bool:
|
|
2089
|
+
"""``flush``'s typed delivery, under its delivery_lock. The queue and the
|
|
2090
|
+
agent are read afresh: while it waited, another sender may have delivered,
|
|
2091
|
+
or the session been paused."""
|
|
2092
|
+
from brindle import launch_health
|
|
2093
|
+
|
|
2094
|
+
if _delivery_held(db, db.get_agent(agent_id)) or db.pending_count(agent_id) == 0:
|
|
2064
2095
|
return False
|
|
2065
2096
|
claimed_at = db.claim_idle(agent_id)
|
|
2066
2097
|
if claimed_at is None:
|
|
2067
|
-
return
|
|
2098
|
+
return _flush_midturn(db, agent_id)
|
|
2099
|
+
agent = db.get_agent(agent_id)
|
|
2100
|
+
assert agent is not None
|
|
2068
2101
|
if _paste_blocked(agent):
|
|
2069
2102
|
# Leave it queued: the next flush (a later message, or a resumed
|
|
2070
2103
|
# session's start-up) gets another chance.
|
|
@@ -2074,8 +2107,6 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
2074
2107
|
if not msg:
|
|
2075
2108
|
db.set_status(agent_id, "idle", only_if="processing")
|
|
2076
2109
|
return False
|
|
2077
|
-
agent = db.get_agent(agent_id)
|
|
2078
|
-
assert agent is not None
|
|
2079
2110
|
from brindle import antigravity
|
|
2080
2111
|
warmup = agent.provider == "antigravity" and antigravity.WARMUP_END in msg.body
|
|
2081
2112
|
try:
|
|
@@ -2092,6 +2123,34 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
2092
2123
|
return True
|
|
2093
2124
|
|
|
2094
2125
|
|
|
2126
|
+
def _flush_midturn(db: DB, agent_id: str) -> bool:
|
|
2127
|
+
"""``_flush_pane`` for a busy interactive chat whose CLI keeps input typed
|
|
2128
|
+
mid-turn until its current step finishes (Codex, agy): the oldest queued
|
|
2129
|
+
message goes in now instead of at the turn-end notify or Stop. Never into
|
|
2130
|
+
an approval prompt ('waiting', or one on screen the hooks haven't
|
|
2131
|
+
reported), where the keys would answer it."""
|
|
2132
|
+
agent = db.get_agent(agent_id)
|
|
2133
|
+
if (agent is None or agent.status != "processing" or agent.mode != "interactive"
|
|
2134
|
+
or not get_provider(agent.provider).types_midturn):
|
|
2135
|
+
return False
|
|
2136
|
+
try:
|
|
2137
|
+
screen = tmux.capture(agent.tmux_window, lines=40, server=server_of(agent))
|
|
2138
|
+
except tmux.TmuxError:
|
|
2139
|
+
return False
|
|
2140
|
+
if get_provider(agent.provider).screen_state(screen) == "waiting" or _paste_blocked(agent):
|
|
2141
|
+
return False
|
|
2142
|
+
msg = db.pop_pending(agent_id)
|
|
2143
|
+
if not msg:
|
|
2144
|
+
return False
|
|
2145
|
+
try:
|
|
2146
|
+
tmux.paste(agent.tmux_window, msg.body, lead=message_lead(db, agent, msg.sender_id),
|
|
2147
|
+
server=server_of(agent))
|
|
2148
|
+
except BaseException:
|
|
2149
|
+
db.release_pending(msg.id, agent_id) # back in the queue for the turn-end flush
|
|
2150
|
+
raise
|
|
2151
|
+
return True
|
|
2152
|
+
|
|
2153
|
+
|
|
2095
2154
|
def _paste_blocked(agent: Agent) -> bool:
|
|
2096
2155
|
"""Whether pasting into ``agent``'s pane right now would land somewhere
|
|
2097
2156
|
other than its chat: over text the person is mid-typing (interactive
|
|
@@ -3346,17 +3405,30 @@ def pre_tool_decision(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
3346
3405
|
from brindle.profiles import profile_rules_for
|
|
3347
3406
|
|
|
3348
3407
|
cfg = load_repo_config(ws.repo_root)
|
|
3349
|
-
|
|
3408
|
+
essentials = tool == "Bash" and cfg.permission_essentials is not False
|
|
3409
|
+
if cfg.permission_policy == "on" or essentials:
|
|
3350
3410
|
req = permissions.from_claude(payload, worktree=ws.path, repo_root=ws.repo_root)
|
|
3351
3411
|
rules = [*permissions.all_rules(ws.repo_root), *profile_rules_for(agent.profile, ws.repo_root),
|
|
3352
3412
|
*permission_grants.limits(ws.repo_root).deny]
|
|
3353
3413
|
restrictions = [r for r in rules if r.decision in ("ask", "deny")]
|
|
3414
|
+
# A deny or ask in force reaches the person before any allow below
|
|
3415
|
+
# (essentials, the profile's allowed_tools) could pass the command.
|
|
3416
|
+
# With the policy off, only a rule's own: syntax brindle can't
|
|
3417
|
+
# check (d-shell-syntax) stays the profile's and Claude Code's call,
|
|
3418
|
+
# as it was before permission_essentials.
|
|
3354
3419
|
decision = permissions.decide(req, checks=cfg.checks, rules=restrictions)
|
|
3355
|
-
if decision.rule is not None:
|
|
3420
|
+
if decision.rule is not None and (cfg.permission_policy == "on" or decision.rule.id != "d-shell-syntax"):
|
|
3356
3421
|
return {"hookSpecificOutput": {
|
|
3357
3422
|
"hookEventName": "PreToolUse", "permissionDecision": "ask",
|
|
3358
3423
|
"permissionDecisionReason": f"brindle: {decision.reason}",
|
|
3359
3424
|
}}
|
|
3425
|
+
# The permission_essentials group, whatever permission_policy says:
|
|
3426
|
+
# read-only inspection no deny or ask covers runs without a prompt.
|
|
3427
|
+
if essentials and req is not None and permissions.essential(req, restrictions, cfg.checks):
|
|
3428
|
+
return {"hookSpecificOutput": {
|
|
3429
|
+
"hookEventName": "PreToolUse", "permissionDecision": "allow",
|
|
3430
|
+
"permissionDecisionReason": "brindle: a read-only command workers need (permission_essentials)",
|
|
3431
|
+
}}
|
|
3360
3432
|
if tool != "Bash":
|
|
3361
3433
|
return None
|
|
3362
3434
|
command = str((payload.get("tool_input") or {}).get("command", ""))
|
|
@@ -3439,7 +3511,10 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
3439
3511
|
# allowed_tools cover and nothing else of brindle's: every other
|
|
3440
3512
|
# answer is Codex's own.
|
|
3441
3513
|
review_only = not policy and provider == "codex"
|
|
3442
|
-
|
|
3514
|
+
# And any Codex worker while permission_essentials is on: its hook
|
|
3515
|
+
# answers those commands alone.
|
|
3516
|
+
essentials = review_only and cfg.permission_essentials is not False
|
|
3517
|
+
if not policy and provider != "claude" and not (review_only and (agent.mode == "review" or essentials)):
|
|
3443
3518
|
return None
|
|
3444
3519
|
profile_rules = profile_rules_for(agent.profile, ws.repo_root)
|
|
3445
3520
|
if provider == "codex":
|
|
@@ -3450,6 +3525,13 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
3450
3525
|
return None
|
|
3451
3526
|
lim = permission_grants.limits(ws.repo_root)
|
|
3452
3527
|
rules = [*permissions.all_rules(ws.repo_root), *profile_rules, *lim.deny]
|
|
3528
|
+
if essentials and len(reqs) == 1 and permissions.essential(
|
|
3529
|
+
reqs[0], [r for r in rules if r.decision in ("ask", "deny")], cfg.checks):
|
|
3530
|
+
decision = permissions.Decision("allow", "a read-only command workers need (permission_essentials)")
|
|
3531
|
+
_record_permission(db, agent, ws, reqs[0].summary(), f"allow: {decision.reason}")
|
|
3532
|
+
return permissions.codex_output(decision)
|
|
3533
|
+
if review_only and agent.mode != "review":
|
|
3534
|
+
return None # as Codex would without brindle: its own prompt or refusal
|
|
3453
3535
|
from dataclasses import replace
|
|
3454
3536
|
|
|
3455
3537
|
from brindle.native.permissions import Permissions, split_commands, uncovered_part
|
|
@@ -3588,6 +3670,11 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
3588
3670
|
# Seconds a hold waits on the daemon at a time between looks at whether the
|
|
3589
3671
|
# agent was closed or its request answered at the CLI's own prompt.
|
|
3590
3672
|
HOLD_LOOK_SECONDS = 5.0
|
|
3673
|
+
# Looks in a row (HOLD_LOOK_SECONDS apart) a pane must read past its CLI's own
|
|
3674
|
+
# permission prompt, busy or idle, before the hold ends: longer than one look,
|
|
3675
|
+
# so a read while the prompt is redrawn doesn't take it for answered. Any
|
|
3676
|
+
# other read starts the count over.
|
|
3677
|
+
ANSWERED_LOOKS = 3
|
|
3591
3678
|
# Seconds a CLI's hook timeout is set above the hold (permission_hold_seconds),
|
|
3592
3679
|
# so the hook gives the request to the prompt itself before the CLI cancels it.
|
|
3593
3680
|
HOOK_SLACK = 30
|
|
@@ -3638,12 +3725,13 @@ def _await_app_answer(db: DB, agent: Agent, holder, req, hold: float) -> str | N
|
|
|
3638
3725
|
``holder`` (brindle.daemon.holds), or None after ``hold`` seconds, when
|
|
3639
3726
|
the person asks to answer in the terminal, once the agent is closed or
|
|
3640
3727
|
killed, once its CLI's own prompt took the request (its PostToolUse
|
|
3641
|
-
removed the row
|
|
3642
|
-
the
|
|
3643
|
-
|
|
3728
|
+
removed the row, or its pane stayed past the prompt for ANSWERED_LOOKS),
|
|
3729
|
+
or if the daemon goes away: then the request goes to the CLI's own
|
|
3730
|
+
prompt (or, where there is none, is refused). The agent shows waiting
|
|
3731
|
+
meanwhile, as at that prompt."""
|
|
3644
3732
|
db.set_status(agent.id, "waiting")
|
|
3645
3733
|
bus.publish_agent(db, "permission.pending", agent.id, tool=req.tool, target=req.target())
|
|
3646
|
-
deadline, answer = time.monotonic() + hold, None
|
|
3734
|
+
deadline, answer, past = time.monotonic() + hold, None, 0
|
|
3647
3735
|
while True:
|
|
3648
3736
|
now = db.get_agent(agent.id)
|
|
3649
3737
|
recorded = db.conn.execute("SELECT 1 FROM permission_requests WHERE agent_id=? AND tool_use_id=?",
|
|
@@ -3652,6 +3740,13 @@ def _await_app_answer(db: DB, agent: Agent, holder, req, hold: float) -> str | N
|
|
|
3652
3740
|
if (now is None or now.status in ("done", "paused") or now.dismissed_at is not None
|
|
3653
3741
|
or recorded is None or left <= 0):
|
|
3654
3742
|
break
|
|
3743
|
+
# Its CLI shows its own prompt for as long as this hook holds the request: a pane
|
|
3744
|
+
# past it was answered there, whether or not a look caught the prompt. An allow
|
|
3745
|
+
# runs the tool (its PostToolUse comes once it is done), a deny or a dismissal none.
|
|
3746
|
+
state = _pane_state(now)
|
|
3747
|
+
past = past + 1 if state in ("idle", "busy") else 0 # the prompt, or a read it can't tell
|
|
3748
|
+
if past >= ANSWERED_LOOKS:
|
|
3749
|
+
break
|
|
3655
3750
|
held, answer = holder.wait(min(left, HOLD_LOOK_SECONDS))
|
|
3656
3751
|
if answer is not None or not held:
|
|
3657
3752
|
break
|
|
@@ -3661,6 +3756,22 @@ def _await_app_answer(db: DB, agent: Agent, holder, req, hold: float) -> str | N
|
|
|
3661
3756
|
return answer
|
|
3662
3757
|
|
|
3663
3758
|
|
|
3759
|
+
def _pane_state(agent: Agent) -> str | None:
|
|
3760
|
+
"""What the pane of an agent whose CLI shows its own prompt while its
|
|
3761
|
+
hook holds a request reads as: 'prompt' (any permission prompt), else
|
|
3762
|
+
'idle', 'busy' or 'waiting', or None."""
|
|
3763
|
+
from brindle.service.inbox import TERMINAL_PROMPT_VISIBLE
|
|
3764
|
+
|
|
3765
|
+
provider = get_provider(agent.provider)
|
|
3766
|
+
if agent.headless or not agent.tmux_window or not TERMINAL_PROMPT_VISIBLE.get(agent.provider):
|
|
3767
|
+
return None
|
|
3768
|
+
try:
|
|
3769
|
+
screen = tmux.capture(agent.tmux_window, lines=60, server=server_of(agent))
|
|
3770
|
+
except tmux.TmuxError:
|
|
3771
|
+
return None
|
|
3772
|
+
return "prompt" if provider.tool_permission(screen) else provider.screen_state(screen)
|
|
3773
|
+
|
|
3774
|
+
|
|
3664
3775
|
def note_permission_outcome(db: DB, agent: Agent, payload: dict) -> None:
|
|
3665
3776
|
"""PostToolUse: if the tool's request had been left to the person, they
|
|
3666
3777
|
approved it. Count that toward a suggestion (never a rule by itself).
|
|
@@ -3837,7 +3948,15 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
3837
3948
|
# 'waiting' before the session even started was its trust dialog
|
|
3838
3949
|
# (see screen_status), which has now been answered.
|
|
3839
3950
|
db.set_status(agent_id, "idle", only_if="waiting")
|
|
3840
|
-
if db.pending_count(agent_id) and not agent.headless:
|
|
3951
|
+
if db.pending_count(agent_id) and not agent.headless and not get_provider(agent.provider).announces_start:
|
|
3952
|
+
# No start hook ran: this is `ready`, called once after_launch saw
|
|
3953
|
+
# the CLI's input box, so it takes input now (agy's warm-up, then
|
|
3954
|
+
# its task, would otherwise wait out ready_delay first).
|
|
3955
|
+
try:
|
|
3956
|
+
flush(db, agent_id)
|
|
3957
|
+
except tmux.TmuxError:
|
|
3958
|
+
pass # stays queued for the next flush
|
|
3959
|
+
elif db.pending_count(agent_id) and not agent.headless:
|
|
3841
3960
|
# Claude Code hasn't drawn its input box yet; deliver shortly after,
|
|
3842
3961
|
# from a detached process so this hook returns immediately.
|
|
3843
3962
|
import subprocess
|
|
@@ -208,12 +208,15 @@ def launch_flags(cwd: str | None, review: bool = False) -> list[str]:
|
|
|
208
208
|
"""The flags a Codex agent in ``cwd`` launches with: brindle's hook when the
|
|
209
209
|
repo's policy is on and the person trusted the hook; else none. A reviewer
|
|
210
210
|
gets it with the policy off too (nobody watches its prompts; the hook only
|
|
211
|
-
allows what its allowed_tools cover, #241),
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
allows what its allowed_tools cover, #241), and so does any worker while
|
|
212
|
+
permission_essentials is on (the hook then only allows those commands),
|
|
213
|
+
but only a hook trusted already: with the policy off brindle never trusts
|
|
214
|
+
a NEW one itself, and a REMOVED trust stays removed."""
|
|
215
|
+
from brindle import permissions
|
|
216
|
+
|
|
214
217
|
try:
|
|
215
218
|
if not policy_on(cwd):
|
|
216
|
-
if not review:
|
|
219
|
+
if not review and not (cwd and permissions.essentials_on(cwd)):
|
|
217
220
|
return []
|
|
218
221
|
command = hook_command()
|
|
219
222
|
if status(command) not in (TRUSTED, MOVED):
|
|
@@ -137,6 +137,10 @@ class RepoConfig:
|
|
|
137
137
|
# "on": brindle answers workers' permission requests by its rules (see
|
|
138
138
|
# brindle.permissions). Unset, it's "on" for Pro and up and "off" on Free.
|
|
139
139
|
permission_policy: str = "off"
|
|
140
|
+
# Allow the read-only commands a worker needs to function (grep, rg, head,
|
|
141
|
+
# tail, cat, wc, ls, sed -n, read-only git; see permissions.essential)
|
|
142
|
+
# without a prompt, whatever permission_policy says. Denies still win.
|
|
143
|
+
permission_essentials: bool = True
|
|
140
144
|
# Seconds brindle's permission hook holds a request left to the person for
|
|
141
145
|
# the app's answer (brindle.agents._hold_for_app): a day, so in practice
|
|
142
146
|
# until the person answers it, sends it to the terminal, or the agent is
|
|
@@ -347,6 +351,7 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
347
351
|
"review_rounds", "review_start_delay", "fable_escalation", "goal_audit", "overlap", "coordinator", "local_models", "merge_into",
|
|
348
352
|
"auto_merge_default_branch", "delete_merged_branches", "delegation", "max_weight", "sidebar", "pr_footer", "auto_pr", "plan_first", "learning",
|
|
349
353
|
"learning_candidates", "limit_cooldown_minutes", "message_delivery", "permission_policy",
|
|
354
|
+
"permission_essentials",
|
|
350
355
|
"permission_hold_seconds", "agy_approvals",
|
|
351
356
|
"learned_rules_repeats", "learned_rules_profile", "exclude_providers",
|
|
352
357
|
"reviewer_fallbacks", "supervisor_profile", "supervisor_fallbacks"):
|
|
@@ -2718,6 +2718,11 @@
|
|
|
2718
2718
|
},
|
|
2719
2719
|
"PermissionPresetDTO": {
|
|
2720
2720
|
"properties": {
|
|
2721
|
+
"description": {
|
|
2722
|
+
"default": "",
|
|
2723
|
+
"title": "Description",
|
|
2724
|
+
"type": "string"
|
|
2725
|
+
},
|
|
2721
2726
|
"id": {
|
|
2722
2727
|
"title": "Id",
|
|
2723
2728
|
"type": "string"
|
|
@@ -2727,13 +2732,28 @@
|
|
|
2727
2732
|
"type": "string"
|
|
2728
2733
|
},
|
|
2729
2734
|
"rule": {
|
|
2730
|
-
"
|
|
2735
|
+
"anyOf": [
|
|
2736
|
+
{
|
|
2737
|
+
"$ref": "#/$defs/PermissionRuleDTO"
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
"type": "null"
|
|
2741
|
+
}
|
|
2742
|
+
],
|
|
2743
|
+
"default": null
|
|
2744
|
+
},
|
|
2745
|
+
"rules": {
|
|
2746
|
+
"default": [],
|
|
2747
|
+
"items": {
|
|
2748
|
+
"$ref": "#/$defs/PermissionRuleDTO"
|
|
2749
|
+
},
|
|
2750
|
+
"title": "Rules",
|
|
2751
|
+
"type": "array"
|
|
2731
2752
|
}
|
|
2732
2753
|
},
|
|
2733
2754
|
"required": [
|
|
2734
2755
|
"id",
|
|
2735
|
-
"name"
|
|
2736
|
-
"rule"
|
|
2756
|
+
"name"
|
|
2737
2757
|
],
|
|
2738
2758
|
"title": "PermissionPresetDTO",
|
|
2739
2759
|
"type": "object"
|
|
@@ -3070,6 +3090,11 @@
|
|
|
3070
3090
|
}
|
|
3071
3091
|
},
|
|
3072
3092
|
"properties": {
|
|
3093
|
+
"description": {
|
|
3094
|
+
"default": "",
|
|
3095
|
+
"title": "Description",
|
|
3096
|
+
"type": "string"
|
|
3097
|
+
},
|
|
3073
3098
|
"id": {
|
|
3074
3099
|
"title": "Id",
|
|
3075
3100
|
"type": "string"
|
|
@@ -3079,13 +3104,28 @@
|
|
|
3079
3104
|
"type": "string"
|
|
3080
3105
|
},
|
|
3081
3106
|
"rule": {
|
|
3082
|
-
"
|
|
3107
|
+
"anyOf": [
|
|
3108
|
+
{
|
|
3109
|
+
"$ref": "#/$defs/PermissionRuleDTO"
|
|
3110
|
+
},
|
|
3111
|
+
{
|
|
3112
|
+
"type": "null"
|
|
3113
|
+
}
|
|
3114
|
+
],
|
|
3115
|
+
"default": null
|
|
3116
|
+
},
|
|
3117
|
+
"rules": {
|
|
3118
|
+
"default": [],
|
|
3119
|
+
"items": {
|
|
3120
|
+
"$ref": "#/$defs/PermissionRuleDTO"
|
|
3121
|
+
},
|
|
3122
|
+
"title": "Rules",
|
|
3123
|
+
"type": "array"
|
|
3083
3124
|
}
|
|
3084
3125
|
},
|
|
3085
3126
|
"required": [
|
|
3086
3127
|
"id",
|
|
3087
|
-
"name"
|
|
3088
|
-
"rule"
|
|
3128
|
+
"name"
|
|
3089
3129
|
],
|
|
3090
3130
|
"title": "PermissionPresetDTO",
|
|
3091
3131
|
"type": "object"
|
|
@@ -1421,6 +1421,12 @@ class DB:
|
|
|
1421
1421
|
c.execute("UPDATE inbox SET delivered_at=? WHERE id=?", (time.time(), row["id"]))
|
|
1422
1422
|
return _load(Message, row)
|
|
1423
1423
|
|
|
1424
|
+
def release_pending(self, message_id: int, agent_id: str) -> None:
|
|
1425
|
+
"""Put a message pop_pending claimed back in the queue, as it was."""
|
|
1426
|
+
with self.tx() as c:
|
|
1427
|
+
c.execute("UPDATE inbox SET delivered_at=NULL WHERE id=? AND agent_id=?",
|
|
1428
|
+
(message_id, agent_id))
|
|
1429
|
+
|
|
1424
1430
|
def retry_pending(self, message_id: int, agent_id: str, idle_since: float) -> None:
|
|
1425
1431
|
"""Release the caller's failed delivery claim without creating a second message.
|
|
1426
1432
|
|
|
@@ -14,12 +14,18 @@ import json
|
|
|
14
14
|
import os
|
|
15
15
|
import time
|
|
16
16
|
from dataclasses import asdict, replace
|
|
17
|
+
from importlib.resources import files
|
|
17
18
|
|
|
18
19
|
from brindle import permissions as p
|
|
19
20
|
from brindle.policy import Decision
|
|
20
21
|
from brindle.service.errors import ServiceError
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def presets() -> list[dict]:
|
|
25
|
+
"""The settings UI and its tests share this backend-owned inventory."""
|
|
26
|
+
return json.loads(files("brindle").joinpath("permission_presets.json").read_text())
|
|
27
|
+
|
|
28
|
+
|
|
23
29
|
def policy(repo: str):
|
|
24
30
|
from brindle.pro.team_policy import ProPolicy
|
|
25
31
|
|
|
@@ -116,7 +122,9 @@ def _state(repo: str, store: p.Store) -> tuple[dict, list[p.Rule]]:
|
|
|
116
122
|
def snapshot(repo: str) -> dict:
|
|
117
123
|
state, _ = _state(_repo(repo), p.load_store())
|
|
118
124
|
return {**state, "catalog": catalog(),
|
|
119
|
-
"presets": [{
|
|
125
|
+
"presets": [{**preset, "rules": [_row(p.Rule.from_dict(raw)) for raw in preset["rules"]]}
|
|
126
|
+
for preset in presets()] +
|
|
127
|
+
[{"id": r.id, "name": r.describe(), "rule": _row(r)} for r in p.PRESET_RULES]}
|
|
120
128
|
|
|
121
129
|
|
|
122
130
|
def _pattern_covers(a: p.Rule, b: p.Rule) -> bool:
|
|
@@ -265,7 +273,7 @@ def _propose(body: dict, store: p.Store) -> tuple[dict, list[p.Rule]]:
|
|
|
265
273
|
if not state["restrictions"]["editable"]:
|
|
266
274
|
raise ServiceError("forbidden", state["restrictions"]["reason"], 403)
|
|
267
275
|
operation, resolution, id_ = body.get("operation"), body.get("resolution", "manual"), body.get("id")
|
|
268
|
-
if operation not in ("upsert", "remove") or resolution not in ("manual", "auto", "existing", "new"):
|
|
276
|
+
if operation not in ("upsert", "remove", "preset") or resolution not in ("manual", "auto", "existing", "new"):
|
|
269
277
|
raise ServiceError("invalid", "unsupported operation or resolution")
|
|
270
278
|
if id_ is not None and not any(r.id == id_ for r in store.rules):
|
|
271
279
|
raise ServiceError("forbidden", "only your editable rules can be changed", 403)
|
|
@@ -273,6 +281,24 @@ def _propose(body: dict, store: p.Store) -> tuple[dict, list[p.Rule]]:
|
|
|
273
281
|
raise ServiceError("invalid", "removing a rule requires id")
|
|
274
282
|
before = list(store.rules)
|
|
275
283
|
after = [r for r in before if r.id != id_]
|
|
284
|
+
if operation == "preset":
|
|
285
|
+
preset = next((item for item in presets() if item["id"] == body.get("preset")), None)
|
|
286
|
+
if preset is None or id_ is not None:
|
|
287
|
+
raise ServiceError("invalid", "choose an available permission preset")
|
|
288
|
+
# Presets replace only this repository's editable rules. Global rules,
|
|
289
|
+
# other repositories and all external protections retain their ownership.
|
|
290
|
+
after = [r for r in before if r.repo_root != repo]
|
|
291
|
+
# Edits retain IDs even when their scope becomes global. Use the
|
|
292
|
+
# reviewed revision for new IDs, reserving every existing identity so
|
|
293
|
+
# reapplying a preset cannot alias an edited or removed rule.
|
|
294
|
+
used_ids = {r.id for r in [*before, *protected]}
|
|
295
|
+
for index, raw in enumerate(preset["rules"]):
|
|
296
|
+
rule = p.Rule.from_dict({**raw, "repo_root": repo})
|
|
297
|
+
identity = _digest([state["revision"], preset["id"], index, rule.to_dict()])[:16]
|
|
298
|
+
while identity in used_ids:
|
|
299
|
+
identity = _digest([state["revision"], identity])[:16]
|
|
300
|
+
used_ids.add(identity)
|
|
301
|
+
after.append(replace(rule, id=identity))
|
|
276
302
|
existing = next((r for r in before if r.id == id_), None)
|
|
277
303
|
draft = _draft(body.get("rule"), repo, existing) if operation == "upsert" else None
|
|
278
304
|
if draft:
|
|
@@ -291,13 +317,19 @@ def _propose(body: dict, store: p.Store) -> tuple[dict, list[p.Rule]]:
|
|
|
291
317
|
raise ServiceError("conflict", "cannot replace protected or unproven overlapping scopes", 409)
|
|
292
318
|
removed = {c["right"] if c["left"] == draft.id else c["left"] for c in relevant}
|
|
293
319
|
after = [r for r in after if r.id not in removed]
|
|
294
|
-
elif resolution == "auto"
|
|
320
|
+
elif (resolution == "auto" or operation == "preset") and not any(
|
|
321
|
+
c["overlap"] == "unknown" for c in found
|
|
322
|
+
if operation != "preset" or not c["protected"]):
|
|
295
323
|
rank = {"allow": 0, "ask": 1, "deny": 2}
|
|
296
324
|
# Take all intersections from the original rules, then apply their
|
|
297
325
|
# exclusions together. This keeps a chain of conflicts deterministic.
|
|
298
326
|
additions: dict[str, list[p.Rule]] = {}
|
|
299
327
|
all_by_id = {r.id: r for r in [*protected, *after]}
|
|
300
328
|
for conflict in found:
|
|
329
|
+
# Preset reviews resolve editable overlaps conservatively. Fixed
|
|
330
|
+
# denies already win and need no exclusions or ownership changes.
|
|
331
|
+
if operation == "preset" and conflict["protected"]:
|
|
332
|
+
continue
|
|
301
333
|
a, b = (all_by_id[conflict[k]] for k in ("left", "right"))
|
|
302
334
|
weak, strong = sorted((a, b), key=lambda r: rank[r.decision])
|
|
303
335
|
if weak.source in ("user", "learned"):
|
|
@@ -316,7 +348,10 @@ def _propose(body: dict, store: p.Store) -> tuple[dict, list[p.Rule]]:
|
|
|
316
348
|
raise ServiceError("conflict", "resolution exceeds supported rule persistence limits", 409)
|
|
317
349
|
if state["restrictions"]["managed"] and not _tightens(before, after):
|
|
318
350
|
raise ServiceError("forbidden", "managed policy permits only proven local tightening", 403)
|
|
319
|
-
|
|
351
|
+
# A protected peer is always a deny, and denies win wherever scopes overlap
|
|
352
|
+
# (permissions.decide), so it is shown but leaves nothing to resolve; no
|
|
353
|
+
# edit could ever resolve it, and counting it would block every save.
|
|
354
|
+
unresolved = [c for c in _conflicts(after, protected) if not c["protected"]]
|
|
320
355
|
# Existing conflicts outside this edit are shown too; saving may never claim
|
|
321
356
|
# to have resolved a policy that still contains contradictory scopes.
|
|
322
357
|
can_save = resolution == "existing" or not unresolved
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "lenient",
|
|
4
|
+
"name": "Lenient",
|
|
5
|
+
"description": "Allow routine file, command and network actions. Ask for MCP and unknown actions.",
|
|
6
|
+
"rules": [
|
|
7
|
+
{"kind": "read", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
8
|
+
{"kind": "write", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
9
|
+
{"kind": "edit", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
10
|
+
{"kind": "bash", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
11
|
+
{"kind": "fetch", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
12
|
+
{"kind": "mcp", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
13
|
+
{"kind": "other", "match": "*", "match_type": "glob", "decision": "ask"}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "balanced",
|
|
18
|
+
"name": "Balanced",
|
|
19
|
+
"description": "Allow file work. Ask before commands, network access, MCP and unknown actions.",
|
|
20
|
+
"rules": [
|
|
21
|
+
{"kind": "read", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
22
|
+
{"kind": "write", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
23
|
+
{"kind": "edit", "match": "*", "match_type": "glob", "decision": "allow"},
|
|
24
|
+
{"kind": "bash", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
25
|
+
{"kind": "fetch", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
26
|
+
{"kind": "mcp", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
27
|
+
{"kind": "other", "match": "*", "match_type": "glob", "decision": "ask"}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "strict",
|
|
32
|
+
"name": "Strict",
|
|
33
|
+
"description": "Ask before every action, including reads and configured checks.",
|
|
34
|
+
"rules": [
|
|
35
|
+
{"kind": "read", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
36
|
+
{"kind": "write", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
37
|
+
{"kind": "edit", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
38
|
+
{"kind": "bash", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
39
|
+
{"kind": "fetch", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
40
|
+
{"kind": "mcp", "match": "*", "match_type": "glob", "decision": "ask"},
|
|
41
|
+
{"kind": "other", "match": "*", "match_type": "glob", "decision": "ask"}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|