From bdb96670287b86c0da1fb1912ceab1b05d126d3b Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 4 Nov 2026 21:23:27 -0500 Subject: [PATCH 1/3] docs(hermes-agent): design plugin-origin sessions and discord on cotal (RIG-8011) Design record for the two Hermes fork patches Matt ruled on in RIG-8011: discord on the cotal platform under a mesh scope, and plugin-origin sessions at bot-owned Discord threads with approval prompts that still reach the operator. Refs RIG-8011, RIG-7938 Ledger-impact: DL-648, DL-649 Co-authored-by: Matt Wilkinson --- .../hermes-agent/decisions/session/DL-648.md | 6 + .../hermes-agent/decisions/session/DL-649.md | 6 + .../hermes-agent/plugin-origin-sessions.md | 269 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 docs/designs/hermes-agent/decisions/session/DL-648.md create mode 100644 docs/designs/hermes-agent/decisions/session/DL-649.md create mode 100644 docs/designs/hermes-agent/plugin-origin-sessions.md diff --git a/docs/designs/hermes-agent/decisions/session/DL-648.md b/docs/designs/hermes-agent/decisions/session/DL-648.md new file mode 100644 index 0000000..95df4e7 --- /dev/null +++ b/docs/designs/hermes-agent/decisions/session/DL-648.md @@ -0,0 +1,6 @@ +--- +id: DL-648 +decision: "Cotal turns may get the core discord toolset (discord_admin stays Discord-only); on peer-mesh and plugin-origin turns, writes need discord.post_channels to hold the target, and reads refuse DM channels and channels outside the operator's guilds" +status: "Active (Matt, 2026-11-04)" +record: ../../plugin-origin-sessions.md#q1-toolset-and-mesh-scope +--- diff --git a/docs/designs/hermes-agent/decisions/session/DL-649.md b/docs/designs/hermes-agent/decisions/session/DL-649.md new file mode 100644 index 0000000..fc2b923 --- /dev/null +++ b/docs/designs/hermes-agent/decisions/session/DL-649.md @@ -0,0 +1,6 @@ +--- +id: DL-649 +decision: "A plugin with allow_origin_sessions may start a session at a bot-owned Discord thread as plugin:, under the session authority, revalidated before it runs; every row such a plugin injects, in any session, runs in a fresh context with the cotal toolset list and mesh scope, and its approval cards reach the operator as once or deny" +status: "Active (Matt, 2026-11-04)" +record: ../../plugin-origin-sessions.md#q2-plugin-origin-bootstrap +--- diff --git a/docs/designs/hermes-agent/plugin-origin-sessions.md b/docs/designs/hermes-agent/plugin-origin-sessions.md new file mode 100644 index 0000000..1f20b1b --- /dev/null +++ b/docs/designs/hermes-agent/plugin-origin-sessions.md @@ -0,0 +1,269 @@ +# Plugin-origin sessions on the Discord platform + +Ledger-impact: DL-648, DL-649 + +A design record, not shipped behavior. Every current-behavior claim names the file and function it +was read from, on the fork's `rigel-release` at `c4093027`; everything else is proposed. + +This record implements Matt's RIG-8011 rulings for RIG-7938: an operator's assistant profile runs +intake on `cotal`, and its handoff plugin posts notes into Discord forum threads the bot created. + +## Decisions + +1. **Q1 A: `discord` on `cotal`** (DL-648). Cotal turns may get the core `discord` toolset, under a + mesh scope. +2. **Q2 B: plugin-origin sessions** (DL-649). An allow-listed plugin may start a session at a + Discord thread origin under the session authority. Approval prompts for secret actions and + similar actions still reach the operator. +3. **Plugin notes run narrow.** Every row that a plugin with `allow_origin_sessions` injects, in a + bootstrapped or an existing session, runs in a fresh context with the `cotal` toolset list and + the mesh scope; `discord_admin` is absent. The note text comes from a peer whoever started the + thread, and this is the conservative choice; Matt can widen it at merge. + +## Problem / Intent + +Three refusals block the use case. `_TOOLSET_PLATFORM_RESTRICTIONS` (`hermes_cli/toolset_scope.py`) +keeps `discord` off `cotal`. `_discord_message_admission` (`plugins/platforms/discord/adapter.py`) +drops the bot's own messages. `_plugin_origin_admissible` and `_dispatch_plugin_origin_injection` +(`gateway/run_plugin_injection.py`) refuse an origin with no `committed_route_envelope` or +session, which a bot-created thread lacks. + +Intent: scoped Discord on Cotal, and a note that starts a turn at once whose approvals reach the +operator. + +## Approach + +### Q1: toolset and mesh scope + +Add `"cotal"` to the `discord` row. `discord` stays in `_DEFAULT_OFF_TOOLSETS` +(`hermes_cli/tools_config.py`); `discord_admin` stays Discord-only. + +Peer text drives cotal turns. In `tools/discord_tool.py`, `_require_post_scope` allows any channel +when `discord.post_channels` is unset, `_create_thread` has no scope check, and `_fetch_messages` +reads any channel. A new `_require_mesh_scope` covers every core action on a peer-mesh or +plugin-origin turn, with no hard-coded channel set (ruled out by RIG-7938): + +- writes need a set `discord.post_channels` that holds the target or its thread parent; +- reads refuse DM and group-DM channels (types 1 and 3) and channels outside the guilds of the + `post_channels` entries. With no list, reads are refused. + +### Q2: plugin-origin bootstrap + +A new descriptor, `plugin_origin_v1`, admits a plugin turn to a route with no human envelope. It +never resembles `native_text_v1`, and `snapshot_automation` is unchanged. + +Admission requires all of: + +- the plugin's own config sets `allow_gateway_injection: true` and the new + `allow_origin_sessions: true`, the allow-list that keeps other injectors out; +- `_plugin_origin_source` passes (own profile, live adapter, routing); +- a Discord thread origin (`chat_type == "thread"`, `thread_id == chat_id`) whose `owner_id`, + read by a REST GET, is the bot user; +- the profile does not set `thread_sessions_per_user`; +- the emergency stop is not engaged (`check_paused` in `agent/estop.py`); internal events skip + `_hm_estop_gate`. + +No user ID is checked: `_plugin_injection_authorized` needs an allowlisted `user_id` +(`_principal_authorized` in `gateway/authz_mixin.py`), so the plugin would have to claim the +operator. The source user is `plugin:` with `is_bot: true`. Threads are shared unless +`thread_sessions_per_user` (`build_session_key`), so the operator's reply joins the session. +Admission calls `_threads.mark_async`, so `_in_bot_thread` admits that reply without a mention. + +Every injection from a plugin with `allow_origin_sessions` is scheduled in a fresh +`contextvars.Context()`: `_schedule` (`gateway/session_authority.py`) creates the drain in the +caller's context, and `_get_approval_mode` honours a bound hosted-room policy. Admission reserves +and publishes the route as `admit_native` does, then commits `{'text', 'plugin_origin_v1'}` through +`admit_session_input` under principal `automation:`, with `identity`, `plugin`, `home`, +`owner`, `route` and `target`. Later notes take this path until the operator's first reply. + +`check` runs at the drain pre-claim and at startup recovery, repeating every admission test plus +shape, principal, owner, route and target. A refused row settles `cancelled` as `cancel_queued` +does (`_settle_cancelled`) and never pauses the thread: it is not the operator's input. The +non-LOCAL pre-claim becomes exhaustive: a head with any unknown descriptor key raises +`permission_denied`. + +`restore` builds an internal event with `allow_gateway_control=False`, so `_handle_message` skips +`_hm_pending_reply_intercepts`: a note cannot answer an approval. `execute_admission` binds a +`plugin_origin_turn` marker for a `plugin_origin_v1` row and for an automation row whose identity +(`["plugin", , ...]`, from `_admit_plugin_injection`) names a plugin with the key; an +unreadable config counts as set. Under the marker, `_resolve_enabled_toolsets_for_source` +(`gateway/run_turn.py`) resolves `platform_toolsets.cotal` and `_require_mesh_scope` applies. + +### How approvals reach the operator + +1. `_set_session_env` (`gateway/run.py`) binds `platform=discord`, so + `_is_gateway_approval_context` holds and `_presence` (`tools/approval.py`) keeps `is_gateway`. +2. The route is in `authority.sessions`, so `TurnRunner.__init__` (`gateway/run_turn_runner.py`) + sets `_approval_owner`, and `_approval_notify_sync` sends the card into the thread. +3. A secret guard's `approve` with `review: true` runs `request_tool_approval` with `review_first`: + a reviewer DENY blocks, and any other verdict asks the human. +4. `_component_check_auth` (`plugins/platforms/discord/adapter_component_auth.py`) accepts an + allowlisted user confirmed by `_component_live_auth`, allow-all, a role-allowlist member or a + pairing-store approval. With `discord.approval_mentions`, the card mentions allowlisted users. +5. A plugin turn is never a viewer turn (`is_viewer_turn_with_approver`), so the whole-script + `execute_code` gate stays. +6. A plugin-origin card offers once or deny only, and nothing persists, as for a smart-DENY + override in `_human_decision`. + +Preconditions, the same as for the operator's own turns in that thread: `_run_approval_gate` +approves under yolo or `approvals.mode: off`; `is_approved` honours earlier permanent grants, which +are the operator's own; smart mode keeps the operator's policy, so the guardian may approve a +dangerous command without a card. A `review: true` rule asks the human in every other mode. + +## Alternatives considered + +- **Synthesize a native envelope.** It mints provenance the platform never delivered. +- **Drop the committed-envelope check.** Any injecting plugin could open any chat. +- **Run the first turn outside the FIFO.** No ordering, no recovery, no `_approval_owner`. +- **Reuse the cron gate on Cotal.** `_require_cron_scope` limits reads to the `post_channels` + entries, which blocks the home channel. +- **Leave mesh reads unscoped.** A peer could read the operator's DM with the bot. Ruled: refuse + DM channels and channels outside the operator's guilds. +- **Note-only bootstrap, or a webhook author that mentions the bot under `allow_bots`.** Rejected: + the operator already ruled that a note wakes its post (RIG-7983 Q2). +- **Pause the FIFO on a refused plugin row.** A revoked plugin would block the operator's replies + until `/reset` (`pause_notice`). + +## Global Constraints + +- One PR on `rigel-release`, at or after `c4093027`. `create_task(context=...)` needs Python 3.11, + the `requires-python` floor. +- `committed_route_envelope`, `snapshot_automation`, `_event_clause` and `check_native_route` keep + their behavior; no plugin check passes `allow_adapter_delegation=True`. +- In `tools/approval.py`, only the plugin-origin card scope changes. +- Tests use fixtures, never live Discord. Fork docs name no deployment IDs. + +## Plan + +### Task 1: `discord` on `cotal`, with the mesh scope + +Interfaces: + +```python +# hermes_cli/toolset_scope.py +_TOOLSET_PLATFORM_RESTRICTIONS = {"discord": {"discord", "cron", "cotal"}, "discord_admin": {"discord"}} +# gateway/session_ingress.py +plugin_origin_turn: ContextVar[bool] = ContextVar('plugin_origin_turn', default=False) +# tools/approval_context.py +def is_plugin_origin_turn() -> bool: ... +# tools/discord_tool.py +_MESH_WRITE_ACTIONS = frozenset({"create_thread", "create_forum_post", "post_message"}) +_DM_CHANNEL_TYPES = frozenset({1, 3}) +def _require_mesh_scope(token: str, action: str, *, channel_id: str, guild_id: str) -> None: ... +``` + +`_require_mesh_scope` returns unless `_is_peer_mesh_approval_context()` or `is_plugin_origin_turn()` +holds; `_run_discord_action` calls it after `_require_cron_scope`. + +Tests: `test_discord_toolset_allowed_on_cotal_when_configured` and +`test_discord_toolset_not_implicit_on_cotal` in `tests/hermes_cli/test_tools_config.py`. +`TestMeshScope` in `tests/tools/test_discord_tool.py`, on a cotal turn and under the marker: writes +outside the list or with no list are refused; a thread under a listed forum passes; DM and +other-guild reads are refused; Discord turns are unchanged. Docs: `toolsets-reference.md`, +`tools-reference.md`. + +### Task 2: bootstrap admission + +Interfaces: + +```python +# gateway/plugin_origin_session.py (new; beside gateway/plugin_keyless_inject.py) +class PluginOriginRefused(Exception): ... # .reason: str +def allowed(plugin_home: Path, plugin_id: str) -> bool: ... +def eligible(runner, source: SessionSource) -> bool: ... +async def admit(runner, authority, adapter, source: SessionSource, *, content: str, + plugin_id: str, plugin_home: Path) -> bool: ... +async def check(authority, ref: SessionRef, row: dict, *, source: SessionSource, adapter) -> str: ... +def restore(authority, ref: SessionRef, row: dict) -> MessageEvent: ... +def narrowed(authority, row: dict) -> bool: ... +# plugins/platforms/discord/adapter.py, DiscordAdapter +async def thread_owned_by_self(self, thread_id: str) -> bool: ... +``` + +`allowed` reads the plugin's config under `_plugin_home_scope` and fails closed. + +Wiring: + +- `run_plugin_injection.py`: `_plugin_origin_admissible` accepts an origin with no envelope when + `eligible` and `allowed` hold. `_dispatch_plugin_origin_injection` takes `plugin_home` and calls + `admit`. For an allowed plugin, `_schedule_plugin_message_injection` uses + `loop.create_task(coro, context=contextvars.Context())`, or off the loop + `loop.call_soon_threadsafe(start, context=contextvars.Context())`. +- `session_authority.py`: `_claim_next` dispatches on the head's descriptor key; a refused + `plugin_origin_v1` head returns `('refused', first)`, and `_drain` settles it outside the claim + lock. In `recover_native_sessions`, a session with no native row whose queued rows are all + `plugin_origin_v1` binds `LiveSession(available_source, route)` once `check` matches + `available_source`'s session key to the descriptor's `route` and `target`. +- `session_ingress.py`: `execute_admission` calls `restore` for `plugin_origin_v1` and binds + `plugin_origin_turn` when `narrowed` holds; `row_turn_author` returns `plugin:`. +- `run_turn.py`: under the marker, `_resolve_enabled_toolsets_for_source` reads the `cotal` list + through `_get_platform_tools`. + +Tests in a new `tests/gateway/test_plugin_origin_session.py`, on a real `GatewayRunner` and +`SessionAuthority` with a stub Discord adapter: + +- Success: one session, one `plugin_origin_v1` row, a `plugin:` source user, a marked thread. +- Refusals leave no session: a missing key, a non-thread origin, a thread the bot does not own, + `thread_sessions_per_user`, another profile, an engaged estop. +- A second note by `session_key` is admitted. An unmentioned operator reply is admitted natively. +- Revocation or an engaged estop before the claim settles the row `cancelled`; the turn never runs, + and the operator's next reply still runs. +- An unknown descriptor head pauses with `permission_denied`. +- Restart recovery runs a queued row, and settles it after a revocation. +- Two origin injections racing one native message give three ordered rows in one session. +- The turn lacks `discord_admin`, and `post_message` outside the list is refused. +- In an existing session with an operator turn, an allowed plugin's note runs under the marker + without `discord_admin`; another plugin's note runs unchanged; an unreadable config binds it. + +`test_origin_injection_is_refused_up_front_until_the_chat_holds_a_human_turn` stays green: its +plugin lacks `allow_origin_sessions`. + +### Task 3: approvals reach the operator + +Interfaces: + +```python +# tools/approval.py, _human_decision +one_shot = smart_denied or approval_context.is_plugin_origin_turn() +# card data, transport flags and grant() use one_shot where they use smart_denied today +``` + +A new `tests/gateway/test_plugin_origin_approval.py` runs a new fixture, +`tests/gateway/fixtures/plugin_origin_approval_peer.py`, in a subprocess, like +`test_shared_session_controls.py`: a real `GatewayRunner`, `SessionAuthority` and `AIAgent` on the +loopback `ModelPeer`, a Discord adapter recording `_send_exec_approval_prompt`, and a plugin with +both keys whose `pre_tool_call` returns `approve` with `review: true`. Each case bootstraps a fresh +thread, in manual mode unless stated: + +- Reviewer approve or escalate: the card reaches the thread, mentions the operator and offers once + and deny only; "once" runs the effect, and a stale "always" answer persists nothing. +- Reviewer deny: no card, no effect. A human deny or a timeout: no effect. +- A dangerous `terminal` command with no plugin rule gets a card. +- An injection from a cotal turn's executor thread, with a bound room policy whose mode is off, + still gets a card, at a bootstrap and in an existing session with an operator turn. +- Parity with the operator's own turn in the thread: under yolo, mode off, a permanent grant, or a + smart guardian approve, both run without a card. A `review: true` rule in smart mode gets a card. + +Docs: the origin section of `website/docs/user-guide/features/plugins.md`. + +## Tasks + +- [ ] Task 1: `discord` on `cotal`, the mesh scope and the plugin-origin marker. +- [ ] Task 2: `plugin_origin_v1` admission, revalidation, settlement, recovery and tests. +- [ ] Task 3: one-shot plugin-origin cards, end-to-end approval tests and plugin docs. + +## Spec impact + +No spec here changes; fork docs change as each task lists. After the repin, the operator's profile +adds `discord` to `platform_toolsets.cotal`, sets both keys on its handoff plugin, and keeps +`discord.post_channels` and `discord.approval_mentions: true`. + +## Ledger impact + +- [DL-648](decisions/session/DL-648.md): `discord` on `cotal`, mesh-scoped. +- [DL-649](decisions/session/DL-649.md): plugin-origin sessions. + +## Sources + +- RIG-8011 (rulings), RIG-7938 and RIG-7983 (parent decisions). +- `RigelBuild/hermes-agent`, `rigel-release` at `c4093027`: the files and functions named above. From a9c1995ee0731789e9d86d78a4e40062c960ceb3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 4 Nov 2026 22:05:07 -0500 Subject: [PATCH 2/3] docs(hermes-agent): plugin-origin authorization, no approval bypasses, full mesh scope (RIG-8011) Review fixes: bootstrap uses its own authorization predicate, plugin-origin approval asks always reach the operator as once/deny cards, search_members is scoped, and a cotal peer turn keeps its deny. Refs RIG-8011 Co-authored-by: Matt Wilkinson --- .../hermes-agent/decisions/session/DL-648.md | 2 +- .../hermes-agent/decisions/session/DL-649.md | 2 +- .../hermes-agent/plugin-origin-sessions.md | 201 +++++++++--------- 3 files changed, 108 insertions(+), 97 deletions(-) diff --git a/docs/designs/hermes-agent/decisions/session/DL-648.md b/docs/designs/hermes-agent/decisions/session/DL-648.md index 95df4e7..7bcb323 100644 --- a/docs/designs/hermes-agent/decisions/session/DL-648.md +++ b/docs/designs/hermes-agent/decisions/session/DL-648.md @@ -1,6 +1,6 @@ --- id: DL-648 -decision: "Cotal turns may get the core discord toolset (discord_admin stays Discord-only); on peer-mesh and plugin-origin turns, writes need discord.post_channels to hold the target, and reads refuse DM channels and channels outside the operator's guilds" +decision: "Cotal turns may get the core discord toolset (discord_admin stays Discord-only); on peer-mesh and plugin-origin turns, writes need discord.post_channels to hold the target, reads refuse DM channels and channels outside the guilds of the post_channels entries, and search_members is limited to those guilds" status: "Active (Matt, 2026-11-04)" record: ../../plugin-origin-sessions.md#q1-toolset-and-mesh-scope --- diff --git a/docs/designs/hermes-agent/decisions/session/DL-649.md b/docs/designs/hermes-agent/decisions/session/DL-649.md index fc2b923..21d62b8 100644 --- a/docs/designs/hermes-agent/decisions/session/DL-649.md +++ b/docs/designs/hermes-agent/decisions/session/DL-649.md @@ -1,6 +1,6 @@ --- id: DL-649 -decision: "A plugin with allow_origin_sessions may start a session at a bot-owned Discord thread as plugin:, under the session authority, revalidated before it runs; every row such a plugin injects, in any session, runs in a fresh context with the cotal toolset list and mesh scope, and its approval cards reach the operator as once or deny" +decision: "A plugin with allow_origin_sessions may start a session at a bot-owned Discord thread as plugin:, authorized by its own predicate, never as the operator; every row such a plugin injects, in any session, is a plugin-origin turn that runs in a fresh context with the cotal toolset list and mesh scope, and its approval asks always reach the operator as once/deny cards; no configured bypass or earlier grant skips them" status: "Active (Matt, 2026-11-04)" record: ../../plugin-origin-sessions.md#q2-plugin-origin-bootstrap --- diff --git a/docs/designs/hermes-agent/plugin-origin-sessions.md b/docs/designs/hermes-agent/plugin-origin-sessions.md index 1f20b1b..243d808 100644 --- a/docs/designs/hermes-agent/plugin-origin-sessions.md +++ b/docs/designs/hermes-agent/plugin-origin-sessions.md @@ -6,7 +6,7 @@ A design record, not shipped behavior. Every current-behavior claim names the fi was read from, on the fork's `rigel-release` at `c4093027`; everything else is proposed. This record implements Matt's RIG-8011 rulings for RIG-7938: an operator's assistant profile runs -intake on `cotal`, and its handoff plugin posts notes into Discord forum threads the bot created. +intake on `cotal`, and its handoff plugin posts notes into bot-created Discord threads. ## Decisions @@ -15,10 +15,11 @@ intake on `cotal`, and its handoff plugin posts notes into Discord forum threads 2. **Q2 B: plugin-origin sessions** (DL-649). An allow-listed plugin may start a session at a Discord thread origin under the session authority. Approval prompts for secret actions and similar actions still reach the operator. -3. **Plugin notes run narrow.** Every row that a plugin with `allow_origin_sessions` injects, in a - bootstrapped or an existing session, runs in a fresh context with the `cotal` toolset list and - the mesh scope; `discord_admin` is absent. The note text comes from a peer whoever started the - thread, and this is the conservative choice; Matt can widen it at merge. +3. **Plugin notes run narrow.** Every row that a plugin with `allow_origin_sessions` injects, in any + session, is a plugin-origin turn: a fresh context, the `cotal` toolset list and the mesh scope, + no `discord_admin`. Its approval asks always reach the operator as once/deny cards; no configured + bypass or earlier grant skips them. The note text comes from a peer whoever started the thread, + so this is the conservative reading; Matt can loosen it at merge. ## Problem / Intent @@ -28,9 +29,6 @@ drops the bot's own messages. `_plugin_origin_admissible` and `_dispatch_plugin_ (`gateway/run_plugin_injection.py`) refuse an origin with no `committed_route_envelope` or session, which a bot-created thread lacks. -Intent: scoped Discord on Cotal, and a note that starts a turn at once whose approvals reach the -operator. - ## Approach ### Q1: toolset and mesh scope @@ -40,97 +38,100 @@ Add `"cotal"` to the `discord` row. `discord` stays in `_DEFAULT_OFF_TOOLSETS` Peer text drives cotal turns. In `tools/discord_tool.py`, `_require_post_scope` allows any channel when `discord.post_channels` is unset, `_create_thread` has no scope check, and `_fetch_messages` -reads any channel. A new `_require_mesh_scope` covers every core action on a peer-mesh or -plugin-origin turn, with no hard-coded channel set (ruled out by RIG-7938): +and `_search_members` reach any channel or guild. A new `_require_mesh_scope` gives each core +action (`_CORE_ACTION_NAMES`) a rule on a peer-mesh or plugin-origin turn. The operator's guilds +are the guilds of the `post_channels` entries; no channel set is hard-coded (RIG-7938): + +- `create_thread`, `create_forum_post`, `post_message`: the list holds the target or its thread + parent; +- `fetch_messages`, `list_forum_posts`: the channel is not a DM or group DM (types 1 and 3) and is + in an operator guild; +- `search_members`: the guild is an operator guild. -- writes need a set `discord.post_channels` that holds the target or its thread parent; -- reads refuse DM and group-DM channels (types 1 and 3) and channels outside the guilds of the - `post_channels` entries. With no list, reads are refused. +With no list, every core action is refused. ### Q2: plugin-origin bootstrap -A new descriptor, `plugin_origin_v1`, admits a plugin turn to a route with no human envelope. It -never resembles `native_text_v1`, and `snapshot_automation` is unchanged. +A new descriptor, `plugin_origin_v1`, admits a plugin turn to a route with no human envelope; +`snapshot_automation` is unchanged. -Admission requires all of: +A new predicate, `_plugin_origin_authorized` in `gateway/run_plugin_injection.py`, admits the +bootstrap. It requires all of: - the plugin's own config sets `allow_gateway_injection: true` and the new `allow_origin_sessions: true`, the allow-list that keeps other injectors out; -- `_plugin_origin_source` passes (own profile, live adapter, routing); -- a Discord thread origin (`chat_type == "thread"`, `thread_id == chat_id`) whose `owner_id`, - read by a REST GET, is the bot user; +- `_plugin_origin_source` passes (own profile, live adapter, routing), and the adapter is Discord; +- a thread origin (`chat_type == "thread"`, `thread_id == chat_id`) whose `owner_id`, read by a + REST GET, is the bot user; - the profile does not set `thread_sessions_per_user`; - the emergency stop is not engaged (`check_paused` in `agent/estop.py`); internal events skip `_hm_estop_gate`. -No user ID is checked: `_plugin_injection_authorized` needs an allowlisted `user_id` -(`_principal_authorized` in `gateway/authz_mixin.py`), so the plugin would have to claim the -operator. The source user is `plugin:` with `is_bot: true`. Threads are shared unless -`thread_sessions_per_user` (`build_session_key`), so the operator's reply joins the session. -Admission calls `_threads.mark_async`, so `_in_bot_thread` admits that reply without a mention. +Bootstrap never calls `_plugin_injection_authorized`: its `_is_user_authorized_for_source` path +(`_principal_authorized` in `gateway/authz_mixin.py`) needs an allowlisted human `user_id`. The +source user is `plugin:` with `is_bot: true`, so nothing records the operator for peer work; +internal events leave `_hm_admit_event` before its user check. Threads are shared +(`build_session_key`), so the operator's reply joins the session, and `_threads.mark_async` lets +`_in_bot_thread` admit it without a mention. Every injection from a plugin with `allow_origin_sessions` is scheduled in a fresh -`contextvars.Context()`: `_schedule` (`gateway/session_authority.py`) creates the drain in the -caller's context, and `_get_approval_mode` honours a bound hosted-room policy. Admission reserves -and publishes the route as `admit_native` does, then commits `{'text', 'plugin_origin_v1'}` through -`admit_session_input` under principal `automation:`, with `identity`, `plugin`, `home`, -`owner`, `route` and `target`. Later notes take this path until the operator's first reply. +`contextvars.Context()`, because `_schedule` (`gateway/session_authority.py`) starts the drain in +the caller's context and `_get_approval_mode` honours a bound hosted-room policy. Admission +reserves and publishes the route as `admit_native` does, then commits +`{'text', 'plugin_origin_v1'}` under principal `automation:`. Later notes take this path +until the operator's first reply. `check` runs at the drain pre-claim and at startup recovery, repeating every admission test plus -shape, principal, owner, route and target. A refused row settles `cancelled` as `cancel_queued` -does (`_settle_cancelled`) and never pauses the thread: it is not the operator's input. The -non-LOCAL pre-claim becomes exhaustive: a head with any unknown descriptor key raises -`permission_denied`. +the descriptor's shape, owner, route and target. A refused row settles `cancelled` +(`_settle_cancelled`) and never pauses the thread. The non-LOCAL pre-claim becomes exhaustive: an +unknown descriptor key raises `permission_denied`. `restore` builds an internal event with `allow_gateway_control=False`, so `_handle_message` skips -`_hm_pending_reply_intercepts`: a note cannot answer an approval. `execute_admission` binds a -`plugin_origin_turn` marker for a `plugin_origin_v1` row and for an automation row whose identity -(`["plugin", , ...]`, from `_admit_plugin_injection`) names a plugin with the key; an -unreadable config counts as set. Under the marker, `_resolve_enabled_toolsets_for_source` -(`gateway/run_turn.py`) resolves `platform_toolsets.cotal` and `_require_mesh_scope` applies. +`_hm_pending_reply_intercepts`. `execute_admission` binds a `plugin_origin_turn` marker for a +`plugin_origin_v1` row and for an automation row whose identity (`["plugin", , ...]`, from +`_admit_plugin_injection`) names a plugin with the key; an unreadable config counts as set. Under +the marker, `_resolve_enabled_toolsets_for_source` (`gateway/run_turn.py`) resolves +`platform_toolsets.cotal` and `_require_mesh_scope` applies. ### How approvals reach the operator -1. `_set_session_env` (`gateway/run.py`) binds `platform=discord`, so - `_is_gateway_approval_context` holds and `_presence` (`tools/approval.py`) keeps `is_gateway`. -2. The route is in `authority.sessions`, so `TurnRunner.__init__` (`gateway/run_turn_runner.py`) - sets `_approval_owner`, and `_approval_notify_sync` sends the card into the thread. +1. `_set_session_env` (`gateway/run.py`) binds `platform=discord`, so `_presence` + (`tools/approval.py`) keeps `is_gateway`. +2. The route is in `authority.sessions`, so `TurnRunner.__init__` sets `_approval_owner`, and + `_approval_notify_sync` sends the card into the thread. 3. A secret guard's `approve` with `review: true` runs `request_tool_approval` with `review_first`: a reviewer DENY blocks, and any other verdict asks the human. -4. `_component_check_auth` (`plugins/platforms/discord/adapter_component_auth.py`) accepts an - allowlisted user confirmed by `_component_live_auth`, allow-all, a role-allowlist member or a - pairing-store approval. With `discord.approval_mentions`, the card mentions allowlisted users. +4. `_component_check_auth` (`plugins/platforms/discord/adapter_component_auth.py`) accepts + allowlisted users (confirmed live), allow-all, role-allowlist members and paired users. With + `discord.approval_mentions`, the card mentions allowlisted users. 5. A plugin turn is never a viewer turn (`is_viewer_turn_with_approver`), so the whole-script `execute_code` gate stays. -6. A plugin-origin card offers once or deny only, and nothing persists, as for a smart-DENY - override in `_human_decision`. - -Preconditions, the same as for the operator's own turns in that thread: `_run_approval_gate` -approves under yolo or `approvals.mode: off`; `is_approved` honours earlier permanent grants, which -are the operator's own; smart mode keeps the operator's policy, so the guardian may approve a -dangerous command without a card. A `review: true` rule asks the human in every other mode. +6. On a plugin-origin turn, `_run_approval_gate`, `check_all_command_guards`, + `check_dangerous_command` and `check_execute_code_guard` test the marker before their bypasses: + yolo, `approvals.mode: off`, the permanent allowlist and `is_approved` grants no longer skip the + ask. A smart-guardian approve becomes a card; a guardian deny blocks with no card. Cards offer + once or deny and persist nothing, like a smart-DENY override in `_human_decision`. Floor blocks + and container skips are unchanged. ## Alternatives considered - **Synthesize a native envelope.** It mints provenance the platform never delivered. - **Drop the committed-envelope check.** Any injecting plugin could open any chat. -- **Run the first turn outside the FIFO.** No ordering, no recovery, no `_approval_owner`. -- **Reuse the cron gate on Cotal.** `_require_cron_scope` limits reads to the `post_channels` - entries, which blocks the home channel. -- **Leave mesh reads unscoped.** A peer could read the operator's DM with the bot. Ruled: refuse - DM channels and channels outside the operator's guilds. -- **Note-only bootstrap, or a webhook author that mentions the bot under `allow_bots`.** Rejected: - the operator already ruled that a note wakes its post (RIG-7983 Q2). -- **Pause the FIFO on a refused plugin row.** A revoked plugin would block the operator's replies - until `/reset` (`pause_notice`). +- **Run the first turn outside the FIFO.** No ordering, recovery or `_approval_owner`. +- **Reuse the cron gate.** It blocks the home channel. +- **Leave mesh reads unscoped.** A peer could read the operator's DMs. Ruled: scope them (Q1). +- **Context-only bootstrap** (record the note, run no turn). Rejected: the ruling requires the + note to wake its post now (RIG-7983 Q2). +- **A webhook author that mentions the bot.** Rejected: `allow_bots` is guild-wide. +- **Pause the FIFO on a refused plugin row.** A revoked plugin would block the operator's replies. ## Global Constraints -- One PR on `rigel-release`, at or after `c4093027`. `create_task(context=...)` needs Python 3.11, - the `requires-python` floor. +- One PR on `rigel-release`, at or after `c4093027`; Python 3.11 (`requires-python`) allows + `create_task(context=...)`. - `committed_route_envelope`, `snapshot_automation`, `_event_clause` and `check_native_route` keep their behavior; no plugin check passes `allow_adapter_delegation=True`. -- In `tools/approval.py`, only the plugin-origin card scope changes. +- `tools/approval.py` changes are limited to the plugin-origin checks in Task 3. - Tests use fixtures, never live Discord. Fork docs name no deployment IDs. ## Plan @@ -158,15 +159,18 @@ holds; `_run_discord_action` calls it after `_require_cron_scope`. Tests: `test_discord_toolset_allowed_on_cotal_when_configured` and `test_discord_toolset_not_implicit_on_cotal` in `tests/hermes_cli/test_tools_config.py`. `TestMeshScope` in `tests/tools/test_discord_tool.py`, on a cotal turn and under the marker: writes -outside the list or with no list are refused; a thread under a listed forum passes; DM and -other-guild reads are refused; Discord turns are unchanged. Docs: `toolsets-reference.md`, -`tools-reference.md`. +outside the list or with no list are refused; a thread under a listed forum passes; DM reads, +other-guild reads and an other-guild `search_members` are refused; Discord turns are unchanged. +Docs: `toolsets-reference.md`, `tools-reference.md`. ### Task 2: bootstrap admission Interfaces: ```python +# gateway/run_plugin_injection.py, GatewayPluginInjectionMixin +async def _plugin_origin_authorized(self, source: SessionSource, *, plugin_id: str, + plugin_home: Path, target: str) -> bool: ... # gateway/plugin_origin_session.py (new; beside gateway/plugin_keyless_inject.py) class PluginOriginRefused(Exception): ... # .reason: str def allowed(plugin_home: Path, plugin_id: str) -> bool: ... @@ -180,29 +184,28 @@ def narrowed(authority, row: dict) -> bool: ... async def thread_owned_by_self(self, thread_id: str) -> bool: ... ``` -`allowed` reads the plugin's config under `_plugin_home_scope` and fails closed. +`allowed` reads the plugin's config under `_plugin_home_scope` and fails closed. `admit` and `check` +call `_plugin_origin_authorized`. Wiring: - `run_plugin_injection.py`: `_plugin_origin_admissible` accepts an origin with no envelope when - `eligible` and `allowed` hold. `_dispatch_plugin_origin_injection` takes `plugin_home` and calls - `admit`. For an allowed plugin, `_schedule_plugin_message_injection` uses - `loop.create_task(coro, context=contextvars.Context())`, or off the loop - `loop.call_soon_threadsafe(start, context=contextvars.Context())`. + `eligible` and `allowed` hold; `_dispatch_plugin_origin_injection` calls `admit`. For an allowed + plugin, `_schedule_plugin_message_injection` passes `context=contextvars.Context()` to + `loop.create_task`, or off the loop to `loop.call_soon_threadsafe`. - `session_authority.py`: `_claim_next` dispatches on the head's descriptor key; a refused - `plugin_origin_v1` head returns `('refused', first)`, and `_drain` settles it outside the claim - lock. In `recover_native_sessions`, a session with no native row whose queued rows are all - `plugin_origin_v1` binds `LiveSession(available_source, route)` once `check` matches - `available_source`'s session key to the descriptor's `route` and `target`. + `plugin_origin_v1` head returns `('refused', first)`, and `_drain` settles it outside the lock. + In `recover_native_sessions`, a session whose queued rows are all `plugin_origin_v1` binds + `LiveSession(available_source, route)` once `check` matches the descriptor's `route` and `target`. - `session_ingress.py`: `execute_admission` calls `restore` for `plugin_origin_v1` and binds `plugin_origin_turn` when `narrowed` holds; `row_turn_author` returns `plugin:`. -- `run_turn.py`: under the marker, `_resolve_enabled_toolsets_for_source` reads the `cotal` list - through `_get_platform_tools`. +- `run_turn.py`: under the marker, `_resolve_enabled_toolsets_for_source` reads the `cotal` list. -Tests in a new `tests/gateway/test_plugin_origin_session.py`, on a real `GatewayRunner` and -`SessionAuthority` with a stub Discord adapter: +Tests in a new `tests/gateway/test_plugin_origin_session.py`, with a stub Discord adapter: -- Success: one session, one `plugin_origin_v1` row, a `plugin:` source user, a marked thread. +- Success: one session, one `plugin_origin_v1` row, a `plugin:` source with `is_bot`, a marked + thread, and no allowlist entry for `plugin:`. A cron job the turn creates and the session + store's origin record `plugin:` as the user, never the operator. - Refusals leave no session: a missing key, a non-thread origin, a thread the bot does not own, `thread_sessions_per_user`, another profile, an engaged estop. - A second note by `session_key` is admitted. An unmentioned operator reply is admitted natively. @@ -215,25 +218,27 @@ Tests in a new `tests/gateway/test_plugin_origin_session.py`, on a real `Gateway - In an existing session with an operator turn, an allowed plugin's note runs under the marker without `discord_admin`; another plugin's note runs unchanged; an unreadable config binds it. -`test_origin_injection_is_refused_up_front_until_the_chat_holds_a_human_turn` stays green: its -plugin lacks `allow_origin_sessions`. +`test_origin_injection_is_refused_up_front_until_the_chat_holds_a_human_turn` stays green. ### Task 3: approvals reach the operator Interfaces: ```python -# tools/approval.py, _human_decision -one_shot = smart_denied or approval_context.is_plugin_origin_turn() -# card data, transport flags and grant() use one_shot where they use smart_denied today +# tools/approval.py +plugin_origin = approval_context.is_plugin_origin_turn() +# _run_approval_gate, check_all_command_guards, check_dangerous_command, check_execute_code_guard: +# yolo, mode off, the permanent allowlist and is_approved return early only when not plugin_origin +def _smart_gate(..., human_present: bool, plugin_origin: bool = False) -> tuple[dict | None, bool]: ... +# plugin_origin: APPROVE asks the human; DENY blocks with no card +one_shot = smart_denied or plugin_origin # _human_decision: card data, transport flags, grant() ``` A new `tests/gateway/test_plugin_origin_approval.py` runs a new fixture, -`tests/gateway/fixtures/plugin_origin_approval_peer.py`, in a subprocess, like -`test_shared_session_controls.py`: a real `GatewayRunner`, `SessionAuthority` and `AIAgent` on the -loopback `ModelPeer`, a Discord adapter recording `_send_exec_approval_prompt`, and a plugin with -both keys whose `pre_tool_call` returns `approve` with `review: true`. Each case bootstraps a fresh -thread, in manual mode unless stated: +`tests/gateway/fixtures/plugin_origin_approval_peer.py`, in a subprocess: a real `GatewayRunner`, +`SessionAuthority` and `AIAgent` on the loopback `ModelPeer`, a Discord adapter recording +`_send_exec_approval_prompt`, and a plugin with both keys whose `pre_tool_call` returns `approve` +with `review: true`. Cases run in manual mode unless stated: - Reviewer approve or escalate: the card reaches the thread, mentions the operator and offers once and deny only; "once" runs the effect, and a stale "always" answer persists nothing. @@ -241,8 +246,14 @@ thread, in manual mode unless stated: - A dangerous `terminal` command with no plugin rule gets a card. - An injection from a cotal turn's executor thread, with a bound room policy whose mode is off, still gets a card, at a bootstrap and in an existing session with an operator turn. -- Parity with the operator's own turn in the thread: under yolo, mode off, a permanent grant, or a - smart guardian approve, both run without a card. A `review: true` rule in smart mode gets a card. +- On a plugin-origin turn, under `--yolo`, session `/yolo`, `approvals.mode: off`, a permanent + grant, a session grant and a permanent-allowlist match, a dangerous `terminal` command, a + `review: true` rule and `execute_code` each still get a once/deny card. +- Smart mode on a plugin-origin turn: a guardian approve gives a once/deny card; a guardian deny + blocks with no card and no effect. +- The operator's own turn in the same thread keeps today's bypasses. +- A `cotal` turn with a peer sender, whose plugin rule or dangerous command would ask, keeps the + peer-mesh deny: no card in any Discord thread or to the peer, and no effect. Docs: the origin section of `website/docs/user-guide/features/plugins.md`. @@ -250,12 +261,12 @@ Docs: the origin section of `website/docs/user-guide/features/plugins.md`. - [ ] Task 1: `discord` on `cotal`, the mesh scope and the plugin-origin marker. - [ ] Task 2: `plugin_origin_v1` admission, revalidation, settlement, recovery and tests. -- [ ] Task 3: one-shot plugin-origin cards, end-to-end approval tests and plugin docs. +- [ ] Task 3: plugin-origin approval checks, end-to-end approval tests and plugin docs. ## Spec impact No spec here changes; fork docs change as each task lists. After the repin, the operator's profile -adds `discord` to `platform_toolsets.cotal`, sets both keys on its handoff plugin, and keeps +adds `discord` to `platform_toolsets.cotal`, sets both plugin keys, and keeps `discord.post_channels` and `discord.approval_mentions: true`. ## Ledger impact From 71fcd7733238c3096ba356e671fb77290d9921e9 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 5 Nov 2026 00:27:14 -0500 Subject: [PATCH 3/3] docs(hermes-agent): spell out the plugin-origin trust boundary and approval surface (RIG-8011) Refs RIG-8011 Co-authored-by: Matt Wilkinson --- .../hermes-agent/plugin-origin-sessions.md | 101 ++++++++++-------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/docs/designs/hermes-agent/plugin-origin-sessions.md b/docs/designs/hermes-agent/plugin-origin-sessions.md index 243d808..7b9167a 100644 --- a/docs/designs/hermes-agent/plugin-origin-sessions.md +++ b/docs/designs/hermes-agent/plugin-origin-sessions.md @@ -18,16 +18,15 @@ intake on `cotal`, and its handoff plugin posts notes into bot-created Discord t 3. **Plugin notes run narrow.** Every row that a plugin with `allow_origin_sessions` injects, in any session, is a plugin-origin turn: a fresh context, the `cotal` toolset list and the mesh scope, no `discord_admin`. Its approval asks always reach the operator as once/deny cards; no configured - bypass or earlier grant skips them. The note text comes from a peer whoever started the thread, - so this is the conservative reading; Matt can loosen it at merge. + bypass or earlier grant skips them. Peers write the note text whoever started the thread, so + this reading is conservative; Matt can loosen it at merge. ## Problem / Intent Three refusals block the use case. `_TOOLSET_PLATFORM_RESTRICTIONS` (`hermes_cli/toolset_scope.py`) keeps `discord` off `cotal`. `_discord_message_admission` (`plugins/platforms/discord/adapter.py`) drops the bot's own messages. `_plugin_origin_admissible` and `_dispatch_plugin_origin_injection` -(`gateway/run_plugin_injection.py`) refuse an origin with no `committed_route_envelope` or -session, which a bot-created thread lacks. +refuse an origin with no `committed_route_envelope` or session, as a bot-created thread is. ## Approach @@ -40,7 +39,9 @@ Peer text drives cotal turns. In `tools/discord_tool.py`, `_require_post_scope` when `discord.post_channels` is unset, `_create_thread` has no scope check, and `_fetch_messages` and `_search_members` reach any channel or guild. A new `_require_mesh_scope` gives each core action (`_CORE_ACTION_NAMES`) a rule on a peer-mesh or plugin-origin turn. The operator's guilds -are the guilds of the `post_channels` entries; no channel set is hard-coded (RIG-7938): +come from a cached REST GET (`_discord_request`) of each `post_channels` entry, taking its +`guild_id`; no channel set is hard-coded (RIG-7938). If any entry fails or has no guild, every read +and search is refused. Writes check the list itself: - `create_thread`, `create_forum_post`, `post_message`: the list holds the target or its thread parent; @@ -52,39 +53,40 @@ With no list, every core action is refused. ### Q2: plugin-origin bootstrap -A new descriptor, `plugin_origin_v1`, admits a plugin turn to a route with no human envelope; -`snapshot_automation` is unchanged. +A new descriptor, `plugin_origin_v1`, admits a plugin turn to a route with no human envelope. -A new predicate, `_plugin_origin_authorized` in `gateway/run_plugin_injection.py`, admits the -bootstrap. It requires all of: +**Trust boundary.** For a `plugin_origin_v1` row, `_plugin_origin_authorized` (new, in +`gateway/run_plugin_injection.py`) is the whole authorization. It reads only: -- the plugin's own config sets `allow_gateway_injection: true` and the new - `allow_origin_sessions: true`, the allow-list that keeps other injectors out; -- `_plugin_origin_source` passes (own profile, live adapter, routing), and the adapter is Discord; +- `allow_gateway_injection` and the new `allow_origin_sessions` in the plugin's routed profile + home, the allow-list that keeps other injectors out; +- the live Discord adapter `_plugin_origin_source` resolves for that profile; - a thread origin (`chat_type == "thread"`, `thread_id == chat_id`) whose `owner_id`, read by a REST GET, is the bot user; -- the profile does not set `thread_sessions_per_user`; -- the emergency stop is not engaged (`check_paused` in `agent/estop.py`); internal events skip - `_hm_estop_gate`. - -Bootstrap never calls `_plugin_injection_authorized`: its `_is_user_authorized_for_source` path -(`_principal_authorized` in `gateway/authz_mixin.py`) needs an allowlisted human `user_id`. The -source user is `plugin:` with `is_bot: true`, so nothing records the operator for peer work; -internal events leave `_hm_admit_event` before its user check. Threads are shared -(`build_session_key`), so the operator's reply joins the session, and `_threads.mark_async` lets +- the emergency stop (`check_paused` in `agent/estop.py`); +- that profile's `thread_sessions_per_user`, which must be false. + +No human principal check runs, and no user id stands in for one. `_dispatch_plugin_origin_injection` +picks the kind first: `plugin_origin_v1` when `eligible` holds (no committed human envelope). That +kind calls `_plugin_origin_authorized` before and instead of `_plugin_injection_authorized`, whose +`_principal_authorized` path (`gateway/authz_mixin.py`) needs an allowlisted human `user_id`; other +kinds keep `_plugin_injection_authorized`. The source user is `plugin:` with `is_bot: true`. The +restored event is internal, and at `c4093027` `_hm_admit_event` (`gateway/run_inbound.py`) returns +at `if is_internal:` before `_is_user_authorized_for_source` and `_hm_estop_gate`. Threads are +shared (`build_session_key`), so the operator's reply joins the session; `_threads.mark_async` lets `_in_bot_thread` admit it without a mention. Every injection from a plugin with `allow_origin_sessions` is scheduled in a fresh -`contextvars.Context()`, because `_schedule` (`gateway/session_authority.py`) starts the drain in -the caller's context and `_get_approval_mode` honours a bound hosted-room policy. Admission -reserves and publishes the route as `admit_native` does, then commits -`{'text', 'plugin_origin_v1'}` under principal `automation:`. Later notes take this path -until the operator's first reply. +`contextvars.Context()`: `_schedule` (`gateway/session_authority.py`) starts the drain in the +caller's context, and `_get_approval_mode` honours a bound hosted-room policy. Admission reserves +and publishes the route as `admit_native` does, then commits `{'text', 'plugin_origin_v1'}` under +principal `automation:`, until the operator's first reply. -`check` runs at the drain pre-claim and at startup recovery, repeating every admission test plus -the descriptor's shape, owner, route and target. A refused row settles `cancelled` -(`_settle_cancelled`) and never pauses the thread. The non-LOCAL pre-claim becomes exhaustive: an -unknown descriptor key raises `permission_denied`. +`check` runs at the drain pre-claim and at startup recovery. It repeats `_plugin_origin_authorized`, +rereading the allow-list keys and `thread_sessions_per_user` from the plugin's routed profile home +each time (a read error or `true` refuses), and checks the descriptor's shape, owner, route and +target. A refused row settles `cancelled` (`_settle_cancelled`) and never pauses the thread. The +non-LOCAL pre-claim becomes exhaustive: an unknown descriptor key raises `permission_denied`. `restore` builds an internal event with `allow_gateway_control=False`, so `_handle_message` skips `_hm_pending_reply_intercepts`. `execute_admission` binds a `plugin_origin_turn` marker for a @@ -95,10 +97,14 @@ the marker, `_resolve_enabled_toolsets_for_source` (`gateway/run_turn.py`) resol ### How approvals reach the operator -1. `_set_session_env` (`gateway/run.py`) binds `platform=discord`, so `_presence` - (`tools/approval.py`) keeps `is_gateway`. -2. The route is in `authority.sessions`, so `TurnRunner.__init__` sets `_approval_owner`, and - `_approval_notify_sync` sends the card into the thread. +1. The fresh context inherits nothing, so every plugin-origin row, bootstrapped or existing, binds + its surface from the stored route: `restore` (or `restore_local_automation` for a keyed note) + takes `live.source` from `authority.sessions`, and `_set_session_env` (`gateway/run.py`) binds + `platform=discord` and the session key from it. `_presence` (`tools/approval.py`) gets + `is_gateway` from that binding. +2. `TurnRunner.__init__` sets `_approval_owner` from `authority.sessions`, and + `_run_conversation_with_approval` registers `_approval_notify_sync` for the route's session key, + which sends the card into the thread. 3. A secret guard's `approve` with `review: true` runs `request_tool_approval` with `review_first`: a reviewer DENY blocks, and any other verdict asks the human. 4. `_component_check_auth` (`plugins/platforms/discord/adapter_component_auth.py`) accepts @@ -150,6 +156,7 @@ def is_plugin_origin_turn() -> bool: ... # tools/discord_tool.py _MESH_WRITE_ACTIONS = frozenset({"create_thread", "create_forum_post", "post_message"}) _DM_CHANNEL_TYPES = frozenset({1, 3}) +def _operator_guilds(token: str, post_channels: frozenset[str]) -> frozenset[str] | None: ... # cached def _require_mesh_scope(token: str, action: str, *, channel_id: str, guild_id: str) -> None: ... ``` @@ -159,9 +166,10 @@ holds; `_run_discord_action` calls it after `_require_cron_scope`. Tests: `test_discord_toolset_allowed_on_cotal_when_configured` and `test_discord_toolset_not_implicit_on_cotal` in `tests/hermes_cli/test_tools_config.py`. `TestMeshScope` in `tests/tools/test_discord_tool.py`, on a cotal turn and under the marker: writes -outside the list or with no list are refused; a thread under a listed forum passes; DM reads, -other-guild reads and an other-guild `search_members` are refused; Discord turns are unchanged. -Docs: `toolsets-reference.md`, `tools-reference.md`. +outside the list or with no list are refused; a thread whose parent is listed passes; DM reads, +other-guild reads and an other-guild `search_members` are refused; an invalid entry or one whose +GET fails refuses every read and search; Discord turns are unchanged. Docs: +`toolsets-reference.md`, `tools-reference.md`. ### Task 2: bootstrap admission @@ -171,7 +179,7 @@ Interfaces: # gateway/run_plugin_injection.py, GatewayPluginInjectionMixin async def _plugin_origin_authorized(self, source: SessionSource, *, plugin_id: str, plugin_home: Path, target: str) -> bool: ... -# gateway/plugin_origin_session.py (new; beside gateway/plugin_keyless_inject.py) +# gateway/plugin_origin_session.py (new) class PluginOriginRefused(Exception): ... # .reason: str def allowed(plugin_home: Path, plugin_id: str) -> bool: ... def eligible(runner, source: SessionSource) -> bool: ... @@ -184,8 +192,7 @@ def narrowed(authority, row: dict) -> bool: ... async def thread_owned_by_self(self, thread_id: str) -> bool: ... ``` -`allowed` reads the plugin's config under `_plugin_home_scope` and fails closed. `admit` and `check` -call `_plugin_origin_authorized`. +`allowed` reads the plugin's config under `_plugin_home_scope` and fails closed. Wiring: @@ -209,10 +216,11 @@ Tests in a new `tests/gateway/test_plugin_origin_session.py`, with a stub Discor - Refusals leave no session: a missing key, a non-thread origin, a thread the bot does not own, `thread_sessions_per_user`, another profile, an engaged estop. - A second note by `session_key` is admitted. An unmentioned operator reply is admitted natively. -- Revocation or an engaged estop before the claim settles the row `cancelled`; the turn never runs, - and the operator's next reply still runs. +- Revocation, `thread_sessions_per_user: true` or an unreadable config in the plugin's profile, or + an engaged estop, before the claim, settles the row `cancelled`; the operator's reply still runs. - An unknown descriptor head pauses with `permission_denied`. -- Restart recovery runs a queued row, and settles it after a revocation. +- Restart recovery runs a queued row, and settles it after a revocation or a + `thread_sessions_per_user` change made across the restart. - Two origin injections racing one native message give three ordered rows in one session. - The turn lacks `discord_admin`, and `post_message` outside the list is refused. - In an existing session with an operator turn, an allowed plugin's note runs under the marker @@ -230,13 +238,12 @@ plugin_origin = approval_context.is_plugin_origin_turn() # _run_approval_gate, check_all_command_guards, check_dangerous_command, check_execute_code_guard: # yolo, mode off, the permanent allowlist and is_approved return early only when not plugin_origin def _smart_gate(..., human_present: bool, plugin_origin: bool = False) -> tuple[dict | None, bool]: ... -# plugin_origin: APPROVE asks the human; DENY blocks with no card one_shot = smart_denied or plugin_origin # _human_decision: card data, transport flags, grant() ``` -A new `tests/gateway/test_plugin_origin_approval.py` runs a new fixture, +A new `tests/gateway/test_plugin_origin_approval.py` runs a fixture, `tests/gateway/fixtures/plugin_origin_approval_peer.py`, in a subprocess: a real `GatewayRunner`, -`SessionAuthority` and `AIAgent` on the loopback `ModelPeer`, a Discord adapter recording +`SessionAuthority` and `AIAgent` on `ModelPeer`, a Discord adapter recording `_send_exec_approval_prompt`, and a plugin with both keys whose `pre_tool_call` returns `approve` with `review: true`. Cases run in manual mode unless stated: @@ -251,6 +258,8 @@ with `review: true`. Cases run in manual mode unless stated: `review: true` rule and `execute_code` each still get a once/deny card. - Smart mode on a plugin-origin turn: a guardian approve gives a once/deny card; a guardian deny blocks with no card and no effect. +- A `session_key` injection into an existing Discord session with an operator turn, under `--yolo`, + `approvals.mode: off` and a permanent grant: a once/deny card reaches that thread each time. - The operator's own turn in the same thread keeps today's bypasses. - A `cotal` turn with a peer sender, whose plugin rule or dangerous command would ask, keeps the peer-mesh deny: no card in any Discord thread or to the peer, and no effect.