Skip to content

[argus patch #175] Owner tier for Slack channel runs, with taint gating - #134

Merged
Nichol4s merged 3 commits into
argusfrom
argus-slack-owner-tier
Nov 9, 2026
Merged

Nichol4s merged 3 commits into
argusfrom
argus-slack-owner-tier

Conversation

@Nichol4s

@Nichol4s Nichol4s commented Nov 9, 2026 •

Copy link
Copy Markdown

Builds on bytedance#174. Cross-owner steering is gone, so every channel run is triggered by the Atlas's own owner. This adds a restricted owner tier for those runs.

In the owner tier, the owner acts with their own tools in a channel (an Asana task, a GitHub issue, a Gmail draft) and can read their private data. Once a turn has read private data, it is gated:

  • only an allowlist may run: public tools, private reads and writes that stay in the owner's accounts. No other writes, no egress, no bash, no sends, no unknown tools.
  • its answer and files reach the router flagged. The router drafts them to the owner, and makes every later post in that thread a draft.

The tier is inert until the router sends tier: owner. The router does that only after the probe lists owner.

tiers.owner (the acropolis overlay ships this in argus-tier-policy.json)

"owner": {
  "memory_mode": "off",
  "pythia_ring_max": "internal",
  "mcp_servers": {"pythia": "http://argus-kb-api:8000/pythia/mcp"},
  "public_tools": ["pythia_query", "pythia_entity_search", "pythia_list_meetings", "pythia_strategy_canon",
                   "slack_channel_history", "slack_search", "web_search", "web_search_exa",
                   "tool_search", "describe_skill", "ask_clarification"],
  "private_read_tools": ["calendar_list_events", "gmail_search_threads", "drive_search"],
  "self_write_tools": ["gmail_create_draft"],
  "write_tools": ["asana_create_task", "asana_create_project", "github_create_issue", "github_add_comment",
                  "gmail_create_draft", "drive_create_doc", "drive_update_doc"],
  "egress_tools": ["web_search", "web_search_exa", "image_search", "web_fetch"],
  "prompt": "optional; {persona}, {channel_label}, {audience}, {trigger_user} as for public"
}

The tool names in private_read_tools and self_write_tools above are examples. The lists are the acropolis side's to fill.

Field by field:

  • Required: public_tools, private_read_tools, self_write_tools, write_tools and egress_tools. Each is a list, and empty lists are allowed.
  • memory_mode: read-only (the default: injected, never written) or off (nothing injected and nothing written: neither the injector's memory half nor MemoryMiddleware is built).
  • pythia_ring_max: defaults to internal.
  • subagents: must be false.
  • mcp_servers: names the knowledge servers. An MCP tool from them can be PUBLIC, and their Pythia caller token is withheld.
  • Also accepted: strip_context, disable_clarification and prompt, as in 如何集成marp使用? bytedance/deer-flow#174.
  • Ignored, with a warning: agent and tools. The owner tier has no fixed agent or ceiling.
  • Validation: a bad entry refuses the owner tier only.

Classification (default deny): what taints

  • WRITE, never taints: the name is in write_tools or self_write_tools.
  • PUBLIC, never taints: the name is in public_tools. For an MCP tool, it must also come from a deployment server whose URL is in mcp_servers. An in-process tool that carries a listed server's prefix (an in-process pythia_query) is not public.
  • PRIVATE, taints: everything else, including private_read_tools, MCP tools and unknown names. Memory injected into the turn (memory_mode: read-only) also counts as a PRIVATE read, labelled memory.

After taint: an allowlist

  • Allowed: PUBLIC tools minus egress_tools, private_read_tools and self_write_tools.
  • self_write_tools is a subset of write_tools, by design. gmail_create_draft is in both. Before taint it is a write like any other. After taint it stays allowed, because it only writes to the owner's own account. A self-write missing from write_tools is allowed too, with a warning.
  • Never allowed: anything in egress_tools, which beats every other list (so web_search is refused although it is PUBLIC, and an egress tool listed as a self-write or private read is refused too). Also anything in write_tools that is not a self-write.
  • Everything else is refused: writes, egress, bash, browser_*, sends, present_files, unknown tools.
  • Before the first PRIVATE call nothing changes: the owner's full ceiling. That first call itself runs.

What a run gets

Relay and probe:

  • The agent is tier_agents.owner, or the channel default when unset (no per-message override).
  • The event is refused without a valid tiers.owner, or when that agent (or the session's default) is not on the stack.
  • Commands stay chat text.
  • GET /api/channels/slack/relay/capabilities lists owner in tiers under the same conditions.

Admission and manager. The policy is the last write, as in bytedance#174, except that the agent is kept:

  • memory_mode from the policy: with read-only, MemoryMiddleware and the summary flush write nothing; with off, nothing is injected either;
  • no subagents and no bootstrap;
  • user_email stripped.

The owner tier is not stamped on the thread, because a later web run there is the owner's own. A thread already stamped public stays public. The strictest tier wins, in the order dm < owner < private < public.

Assembly:

  • The agent's own tools, minus task, batch_*, read_batch_result, invoke_acp_agent, update_agent and setup_agent. Subagents and external agents run outside the guard, and update_agent would let channel text rewrite the owner's agent.
  • No memory tools. Memory is injected only when read-only, and off (what acropolis will ship) builds no memory writer and no memory injection.
  • The agent's own skills.
  • Tool deferral as configured.
  • Pythia retrieval clamped to internal.

Taint is handled by TierToolGuardMiddleware, the same per-call guard bytedance#174 uses. It wraps every tool call, built-in and MCP.

  • A call is "after taint" when another call of the run, earlier or in the same model step, is PRIVATE, or memory was injected.
  • The run boundary is the worker's pre-run message IDs, so earlier turns do not count. A write in the same step as a PRIVATE read is seen before either one runs.
  • After taint, a call outside the allowlist gets this error ToolMessage: "This turn read private data, so it can't write or search the web. Ask again in a new message to act."
  • after_model and after_agent stamp the run's AI messages with argus_turn_private: [labels]. A label is the MCP server key, else the tool name's first word, or memory.

Signal to the router. The manager copies the turn's stamps onto the outbound. On the relay transport, the Slack adapter posts that answer, and every one of its attachments, through a client that sends two headers:

  • X-Argus-Turn-Private: 1
  • X-Argus-Turn-Sources: calendar,gmail (sorted, header-safe, at most 16 labels of 32 characters)

The headers ride chat.postMessage and, for each file, files.getUploadURLExternal and files.completeUploadExternal. present_files output arrives as those attachments.

The "Working on it" ack, the reactions and the ack cleanup go through the plain client. File bytes go to Slack's upload URL without the headers. An untainted turn sends no new headers on its post or its uploads.

Taint stays per turn. The router makes later posts in a thread drafts once it has seen the header, and a new turn may write again under the allowlist rule.

MCP:

  • Every server gets X-Argus-Run-Tier: owner. That is the same value an untiered run already sends; atlas-mcp and kb-api should treat it as the full owner.
  • The Pythia caller token is withheld only on mcp_servers, which caps Pythia at the internal ring.
  • db and bq keep the owner's identity.
  • All policy-file access stays off the event loop, as in the 如何集成marp使用? bytedance/deer-flow#174 fix.

Simplified, or worth deciding on the acropolis side

  • History carries over. Private data that an earlier tainted turn left in the thread history (or that the owner's web turns put there) is in a later turn's context. The router's sticky drafts keep those answers out of the channel. A later untainted turn may still write or search with that data under the allowlist rule, because taint is per turn by decision.
  • present_files and view_image are PRIVATE. The first call taints the turn, and its files go up flagged. After another private read, they are refused unless private_read_tools lists them. Listing them keeps them PRIVATE but usable after taint.
  • Infra tools when listed in public_tools. tool_search, describe_skill and ask_clarification are PUBLIC: they never taint and stay allowed after taint. A tool tool_search promotes is then classified by its own name. ask_clarification never interrupts here, because clarification is disabled in this tier.
  • Same-step PRIVATE calls. A PRIVATE call in the same model step as another PRIVATE call is after taint. So two unlisted PRIVATE calls in one step are both refused, while a listed private read alongside them runs.
  • Name-only matching. private_read_tools, self_write_tools and write_tools match by name only. Only PUBLIC MCP tools are bound to a server URL.
  • owner changed meaning. In 如何集成marp使用? bytedance/deer-flow#174, owner was the label for "no tier". Now it names the owner tier. Untiered runs are unchanged and still send X-Argus-Run-Tier: owner.

Carry justification

The classes, the guard and the signal live in lead-agent assembly, the tool-call path and the Slack adapter's post. None of these has an overlay seam. The policy ships from acropolis in the image.

Class: argus-edit. It builds on bytedance#174's argus-additive modules. Delete-when: with bytedance#174.

Upstream-file footprint (lines added/removed, app code excluding tests and docs):

File Lines
slack.py +63/-16
agent.py +10/-7
manager.py +6/-1

config.example.yaml also gains two lines. The rest is in bytedance#174's tier_policy.py, tier_tool_guard_middleware.py and run_tier.py. No config_version bump.

Tests

backend/tests/test_slack_owner_tier.py is new, with 48 cases.

  • The policy:
    • the owner entry's defaults;
    • a bad entry refuses the owner tier only (each of the five lists is required);
    • ceiling keys are ignored;
    • memory can be switched off;
    • ranking.
  • Default-deny classification:
    • an unknown tool, a non-listed MCP tool, an MCP tool from a server outside the listed URLs, and an in-process lookalike are all PRIVATE;
    • infra tools are PUBLIC when listed, and present_files/view_image are PRIVATE;
    • labels and headers.
  • The after-taint allowlist:
    • PUBLIC minus egress, private reads and self-writes are allowed;
    • egress, writes, bash, browsers, sends, present_files and unknown tools are refused;
    • gmail_create_draft in both write_tools and self_write_tools runs after taint, while github_create_issue (write only) is refused;
    • egress beats self_write_tools and private_read_tools;
    • a self-write missing from write_tools is allowed, with a warning.
  • The guard:
    • before taint, everything in the ceiling runs, and so does the first PRIVATE call itself;
    • after taint, a private read, a self-write and a PUBLIC tool run;
    • after taint, a write, egress that is also PUBLIC, bash, a send, present_files and an unknown tool are refused;
    • same-step siblings;
    • an unknown tool taints;
    • earlier turns don't count, with and without the worker boundary;
    • injected memory taints;
    • unbound tools are still refused;
    • only a tainted answer is stamped;
    • the async path;
    • the manager reads the stamp.
  • A real LangGraph agent, sync and async: it refuses an Asana write after a calendar read and stamps the final answer.
  • Assembly:
    • the owner's agent and tools stay, minus subagents, update_agent and memory tools;
    • memory_mode: off: no memory injection, no MemoryMiddleware, memory mode off at admission, memory_write_allowed false;
    • the Pythia ring is capped at internal in build_middlewares.
  • Admission:
    • memory is read-only, and memory_write_allowed is false, so no memory write happens in an owner turn;
    • no thread stamp;
    • a public thread stays public.
  • MCP:
    • every server gets owner;
    • no caller token on pythia only;
    • untiered runs are unchanged.
  • Relay: with and without tier_agents.owner, and the refusals.
  • The probe: lists owner only when the policy and agent resolve.
  • The headers:
    • only on the tainted answer and its file, never on acks, reactions or untainted answers;
    • through the channel's own dispatch, on every post and upload of a tainted turn and on none of a clean one.
  • End to end: an owner run through a real manager and relay channel posts its answer flagged.

backend/tests/blocking_io/test_tier_policy_offloop.py adds owner admission, owner MCP calls, the probe, and the guard plus the flagged post, all under the strict gate. make test-blocking-io is green.

backend/tests/test_slack_public_tier.py: owner is now restricted.

Ledger: the bytedance#175 section and TOC row are in, plus a bytedance#175 note in bytedance#174's section. scripts/check_patches_ledger.py passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LyvERcWGR5Xj4WmoBEtpGx

Nichol4s and others added 3 commits November 9, 2026 09:43
…aint gating

The router now sends tier "owner" for a channel run it verified the
Atlas's own owner triggered. That run keeps the owner's agent
(tier_agents.owner, else the default) and tools, minus subagents and
update_agent; reads memory but never writes it; and caps Pythia at the
internal ring.

- tiers.owner in argus-tier-policy.json classes tools as PUBLIC, WRITE or
  PRIVATE; anything not listed, MCP tools included, is PRIVATE.
- The first PRIVATE read in a run (or injected memory) taints it: the
  tier guard refuses its WRITE and egress calls with a tool error and
  stamps its AI messages with the source labels.
- The manager carries the stamp to the outbound, and the Slack adapter
  posts that answer and its files with X-Argus-Turn-Private: 1 and
  X-Argus-Turn-Sources; acks and reactions stay unflagged.
- MCP calls send X-Argus-Run-Tier: owner; the caller token is withheld
  only on the policy's knowledge servers.
- The capability probe lists owner when tiers.owner and its agent resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyvERcWGR5Xj4WmoBEtpGx
…vate reads and self-writes

Nicholas's decisions on the owner tier's gaps:

- tiers.owner gains two required lists, private_read_tools and
  self_write_tools. Before the first PRIVATE call a run keeps the owner's
  full ceiling; after it only PUBLIC tools, private_read_tools and
  self_write_tools may run, never anything in egress_tools or write_tools.
  Writes, egress, bash, browsers, sends and unknown tools get the "ask
  again to act" tool error. Private reads still taint; self-writes, like
  write_tools, do not.
- The first PRIVATE call itself runs: a call is after taint when another
  call of the run, earlier or in the same step, is PRIVATE.
- memory_mode "off" (what acropolis ships) builds neither the memory
  injector's memory half nor MemoryMiddleware, and admission sets the run
  to off; a test pins both.
- Tests: the allowlist (including egress that is also public, and the
  egress/write lists winning), the first private call, same-step
  siblings, infra tools as PUBLIC when listed, and every post and upload
  of a tainted turn flagged through the channel's own dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyvERcWGR5Xj4WmoBEtpGx
…ough they are writes

self_write_tools is a subset of write_tools by design: gmail_create_draft
is a write before taint and stays allowed after it, because it only
writes to the owner's own account. After taint the allowlist is
(public_tools - egress_tools) | private_read_tools | self_write_tools;
write_tools is no longer subtracted from the self-writes, and
egress_tools still beats every list. A self-write missing from
write_tools is allowed with a warning.

Checked against the acropolis policy file (acropolis PR bytedance#1428): owner
validates, memory off, and the probe lists owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyvERcWGR5Xj4WmoBEtpGx
@Nichol4s
Nichol4s merged commit 6ddb93a into argus Nov 9, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant