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.
Files changed (195) hide show
  1. {brindle-0.4.6 → brindle-0.4.7}/PKG-INFO +1 -1
  2. {brindle-0.4.6 → brindle-0.4.7}/pyproject.toml +1 -1
  3. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/agents.py +143 -24
  4. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codex_hook.py +7 -4
  5. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/config.py +5 -0
  6. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/api-schema.json +46 -6
  7. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/db.py +6 -0
  8. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permission_editor.py +39 -4
  9. brindle-0.4.7/src/brindle/permission_presets.json +44 -0
  10. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permissions.py +328 -16
  11. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/providers.py +21 -5
  12. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/dto.py +3 -1
  13. {brindle-0.4.6 → brindle-0.4.7}/.gitignore +0 -0
  14. {brindle-0.4.6 → brindle-0.4.7}/LICENSE +0 -0
  15. {brindle-0.4.6 → brindle-0.4.7}/README.md +0 -0
  16. {brindle-0.4.6 → brindle-0.4.7}/SCHEDULE-A +0 -0
  17. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/__init__.py +0 -0
  18. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/__main__.py +0 -0
  19. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/account.py +0 -0
  20. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/airgap.py +0 -0
  21. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/antigravity.py +0 -0
  22. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assembler.py +0 -0
  23. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assets/braid_dark.png +0 -0
  24. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/assets/braid_light.png +0 -0
  25. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/autopilot.py +0 -0
  26. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/budget.py +0 -0
  27. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/__init__.py +0 -0
  28. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  29. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/coordinator.md +0 -0
  30. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-codex.md +0 -0
  31. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-fable.md +0 -0
  32. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  33. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  34. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-light.md +0 -0
  35. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-local.md +0 -0
  36. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-opencode.md +0 -0
  37. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer-sonnet.md +0 -0
  38. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/developer.md +0 -0
  39. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/qa.md +0 -0
  40. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  41. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  42. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  43. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer-opencode.md +0 -0
  44. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/reviewer.md +0 -0
  45. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/subagent.md +0 -0
  46. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_agents/supervisor.md +0 -0
  47. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/__init__.py +0 -0
  48. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/security/backend.md +0 -0
  49. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  50. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/builtin_rules/tests/only.md +0 -0
  51. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/bus.py +0 -0
  52. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_adapters.py +0 -0
  53. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_client.py +0 -0
  54. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_federation.py +0 -0
  55. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/ci_hosts.py +0 -0
  56. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cli.py +0 -0
  57. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/clone.py +0 -0
  58. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cloud_credential.py +0 -0
  59. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cloud_login.py +0 -0
  60. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codemap.py +0 -0
  61. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/codemap_docs.py +0 -0
  62. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/company_identity.py +0 -0
  63. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/company_login.py +0 -0
  64. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/conflicts.py +0 -0
  65. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/coordinator.py +0 -0
  66. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cost.py +0 -0
  67. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cost_estimate.py +0 -0
  68. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/cull.py +0 -0
  69. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/__init__.py +0 -0
  70. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/app.py +0 -0
  71. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/auth.py +0 -0
  72. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/events.py +0 -0
  73. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/grants.py +0 -0
  74. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/holds.py +0 -0
  75. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/lifecycle.py +0 -0
  76. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/peer.py +0 -0
  77. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_actions.py +0 -0
  78. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_org_permissions.py +0 -0
  79. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_permissions.py +0 -0
  80. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/routes_read.py +0 -0
  81. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/schema.py +0 -0
  82. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/session_review.py +0 -0
  83. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/daemon/terminal.py +0 -0
  84. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/debuglog.py +0 -0
  85. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/demo.py +0 -0
  86. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/detect.py +0 -0
  87. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/doctor.py +0 -0
  88. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/escapes.py +0 -0
  89. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/events.py +0 -0
  90. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/gates.py +0 -0
  91. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/git.py +0 -0
  92. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/guardrails.py +0 -0
  93. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/history.py +0 -0
  94. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/identity_lock.py +0 -0
  95. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/inbox.py +0 -0
  96. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/kb.py +0 -0
  97. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/key_check.py +0 -0
  98. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/keys_cmds.py +0 -0
  99. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/keystore.py +0 -0
  100. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/knowledge.py +0 -0
  101. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/launch_health.py +0 -0
  102. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/learned_rules.py +0 -0
  103. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/learning.py +0 -0
  104. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/legacy.py +0 -0
  105. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/local_setup.py +0 -0
  106. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/login_pause.py +0 -0
  107. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/mcp_server.py +0 -0
  108. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/model_access.py +0 -0
  109. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/model_availability.py +0 -0
  110. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/__init__.py +0 -0
  111. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/catalog.py +0 -0
  112. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/client.py +0 -0
  113. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/loop.py +0 -0
  114. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/lsp.py +0 -0
  115. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/permissions.py +0 -0
  116. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/runner.py +0 -0
  117. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/serve.py +0 -0
  118. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/native/tools.py +0 -0
  119. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/opencode.py +0 -0
  120. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/org_permissions.py +0 -0
  121. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pane_auth.py +0 -0
  122. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/permission_grants.py +0 -0
  123. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/picker.py +0 -0
  124. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pipeline.py +0 -0
  125. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/plugins.py +0 -0
  126. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/policy.py +0 -0
  127. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pool.py +0 -0
  128. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/preflight.py +0 -0
  129. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pricing.py +0 -0
  130. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/__init__.py +0 -0
  131. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/_files.py +0 -0
  132. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/account.py +0 -0
  133. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/audit_chain.py +0 -0
  134. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/audit_export.py +0 -0
  135. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/auth.py +0 -0
  136. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/brief_learning.py +0 -0
  137. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/cost_centers.py +0 -0
  138. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/credentials.py +0 -0
  139. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/features.py +0 -0
  140. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/keys.py +0 -0
  141. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/learning.py +0 -0
  142. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/license.py +0 -0
  143. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/loopback.py +0 -0
  144. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/managed_models.py +0 -0
  145. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/org_profiles.py +0 -0
  146. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/orgkey.py +0 -0
  147. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/rollout.py +0 -0
  148. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/route_health.py +0 -0
  149. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/settings_sync.py +0 -0
  150. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/status.py +0 -0
  151. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/team_events.py +0 -0
  152. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/pro/team_policy.py +0 -0
  153. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/procs.py +0 -0
  154. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/profiles.py +0 -0
  155. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/prompt_answer.py +0 -0
  156. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/provider_homes.py +0 -0
  157. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/providers_install.py +0 -0
  158. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/quota.py +0 -0
  159. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/repo_cmds.py +0 -0
  160. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/repos.py +0 -0
  161. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/rewind.py +0 -0
  162. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/rule_checks.py +0 -0
  163. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/savings.py +0 -0
  164. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/scratch.py +0 -0
  165. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/secrets.py +0 -0
  166. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/self_serve.py +0 -0
  167. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/__init__.py +0 -0
  168. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/errors.py +0 -0
  169. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/inbox.py +0 -0
  170. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/routing.py +0 -0
  171. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/service/sessions.py +0 -0
  172. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/services.py +0 -0
  173. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sessions.py +0 -0
  174. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sidebar_follow.py +0 -0
  175. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/sidebar_logo.py +0 -0
  176. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/signin_pause.py +0 -0
  177. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/status_cache.py +0 -0
  178. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/tasks.py +0 -0
  179. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/tmux.py +0 -0
  180. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/updates.py +0 -0
  181. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/upgrade.py +0 -0
  182. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/usage.py +0 -0
  183. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/view.py +0 -0
  184. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/wake.py +0 -0
  185. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/watch.py +0 -0
  186. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/watch_costs.py +0 -0
  187. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/__init__.py +0 -0
  188. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/chat.py +0 -0
  189. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/dashboard.py +0 -0
  190. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/diff.py +0 -0
  191. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/server.py +0 -0
  192. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/static/app.js +0 -0
  193. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/web/static/index.html +0 -0
  194. {brindle-0.4.6 → brindle-0.4.7}/src/brindle/worker_models.py +0 -0
  195. {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.6
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.4.6"
3
+ version = "0.4.7"
4
4
  description = "Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 ((provider.warmup(profile) if not resume else None), prompt):
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. A chat with a live
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 agent is None or agent.headless:
2044
- return False # its runner takes messages from the inbox itself
2045
- if login_pause.holding(db, agent_id):
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
- if db.pending_count(agent_id) == 0:
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 False
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
- if cfg.permission_policy == "on":
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
- if not policy and provider != "claude" and not (review_only and agent.mode == "review"):
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), or if the daemon goes away: then the request goes to
3642
- the CLI's own prompt (or, where there is none, is refused). The agent
3643
- shows waiting meanwhile, as at that prompt."""
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), but only a hook trusted
212
- already: with the policy off brindle never trusts a NEW one itself, and a
213
- REMOVED trust stays removed."""
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
- "$ref": "#/$defs/PermissionRuleDTO"
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
- "$ref": "#/$defs/PermissionRuleDTO"
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": [{"id": r.id, "name": r.describe(), "rule": _row(r)} for r in p.PRESET_RULES]}
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" and not any(c["overlap"] == "unknown" for c in found):
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
- unresolved = _conflicts(after, protected)
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
+ ]