Repository navigation
feat(discord-bridge): contextNotFrom gate — bar a channel from pulling context from blacklisted channels/guilds - #1698
Conversation
…g context from blacklisted channels/guilds Adds an information-flow gate to the Discord-state prefetch: a channel can declare, in access.json, which channels/guilds its replies must NOT pull context from. Mirrors allowFrom's per-channel shape (allowFrom = who may send; contextNotFrom = which sources may feed a reply). - load_channel_context_blacklist(channel_id) reads per-channel `contextNotFrom` (a set of ids). - _prefetch_discord_state_refs now takes the originating channel id and skips any referenced channel whose CHANNEL-id OR GUILD-id is in that channel's contextNotFrom. GUILD-AWARE: a single guild id blocks every channel in that guild (incl. future ones), no enumeration. Example: #dev → contextNotFrom: ["<private-guild-id>"] means a task in #dev can never pull any channel in that private guild, even if the text references one. Scope note: the prefetch currently runs for team/other-tier tasks; extending it to cover owner-tier public-channel tasks is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rs + agent's own reads The prefetch gate (#1698) only ran for team/other and only covered <#ref> prefetch. Susan's requirement: skip a channel's content when BUILDING context, for ALL tiers (owner included), on EVERY path that pulls a channel into context — a private channel's content can't be un-seen once in context, so the only reliable point is ingestion. - discord-bridge.py: run the contextNotFrom-gated prefetch for all tiers (if True); keep silent-escalate non-owner-only (else -> elif team/other). - read_discord_channel.py (new): the sole sanctioned channel reader. Applies the SERVING channel's contextNotFrom blacklist before fetching (serving-relative: serve #pr-review can read it; serve #dev cannot). Fail-closed: unresolvable target guild + non-empty blacklist -> block. Sends a real User-Agent (Discord 403s urllib's default). - CLAUDE.md: forbid raw-curling channel messages; route reads through the helper. - tests/read-discord-channel-gate.test.py: serving-relative, channel/guild-level, fail-closed. Proven live: serve #dev->#pr-review BLOCKED, serve #pr-review->self OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
855e428 to
5c75e52
Compare
… + vendored hook A private channel's content can't be un-seen once it's in the model's context, so contextNotFrom must be enforced at the READ, on every path that pulls a channel in: - src/read_discord_channel.py: the sole sanctioned channel reader — serving-relative blacklist, fail-closed, real User-Agent (Discord 403s urllib's default). - hooks/context-source-guard.py (+ README): PreToolUse hook that DENIES a raw-curl channel read whose channel/guild is in the SERVING channel's contextNotFrom — the path an instruction can't force. Serving-relative (Read- and cat-path detection), fail-closed. Deployed config like provenance-guard; config paths env-overridable. - src/discord-bridge.py: prefetch gate runs for ALL tiers; silent-escalate stays non-owner-only. - CLAUDE.md: forbid raw-curling channel messages; route through the reader. - tests use FICTITIOUS ids only (no real channel/guild ids in a public repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5c75e52 to
bef0e70
Compare
… + vendored hook A private channel's content can't be un-seen once it's in the model's context, so contextNotFrom is enforced at the READ. The gate is NARROW — it only blocks reading a channel's messages whose channel/guild is in the SERVING channel's contextNotFrom; all other channel API use (public reads, posting, reactions, listing) is untouched. - hooks/context-source-guard.py (+ README): PreToolUse hook — the precise code enforcement. Serving-relative (Read- and cat-path detection), fail-closed, fail-open for everything non-blacklisted. Deployed config like provenance-guard; env-overridable. - src/read_discord_channel.py: the graceful gated reader (same blacklist, clear exit 2). - src/discord-bridge.py: prefetch gate runs for ALL tiers; silent-escalate stays non-owner. - CLAUDE.md: narrow guidance — gate does NOT restrict channel API calls in general. - tests use FICTITIOUS ids only (no real channel/guild ids in a public repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72831dd to
ab6c8f8
Compare
… + vendored hook A private channel's content can't be un-seen once it's in the model's context, so contextNotFrom is enforced at the READ. The gate is NARROW — it only blocks reading a channel's messages whose channel/guild is in the SERVING channel's contextNotFrom; all other channel API use (public reads, posting, reactions, listing) is untouched. - hooks/context-source-guard.py (+ README): PreToolUse hook — the precise code enforcement. Serving-relative (Read- and cat-path detection), fail-closed, fail-open for non-blacklisted. Deployed config like provenance-guard; env-overridable. - src/read_discord_channel.py: graceful gated reader (same blacklist, clear exit 2). Uses claude_home_path() — no hand-rolled ~/.claude paths (host-cli snapshot clean). - src/discord-bridge.py: prefetch gate runs for ALL tiers; silent-escalate non-owner. - CLAUDE.md: narrow guidance — gate does NOT restrict channel API calls in general. - tests use FICTITIOUS ids only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Cold review (Pro) — contextNotFrom context-isolation gate. Strong feature, right design (serving-relative: a private channel can still read itself; only cross-context pulls are barred; guild-level entries block a whole guild incl. future channels; applies to all tiers incl. owner for context-build). Good test coverage. One should-fix on the backstop, plus two limitations worth noting.
[should-fix] The hook (context-source-guard.py) hardcodes legacy paths, so on a relocated install it reads a different access.json than the bridge writes — and silently fails open.
Two of the three new components resolve config correctly: read_discord_channel.py uses claude_home_path("channels","discord","access.json"), and discord-bridge.py's load_channel_context_blacklist uses the module ACCESS_FILE (also claude_home_path). But the hook hardcodes:
ACCESS_FILE = os.environ.get("SUTANDO_DISCORD_ACCESS_FILE", os.path.expanduser("~/.claude/channels/discord/access.json"))
WS = os.path.expanduser(os.environ.get("SUTANDO_WORKSPACE", "~/.sutando/workspace")...)On a node where $CLAUDE_CONFIG_DIR is relocated (verified on a live host: bridge reads $CLAUDE_CONFIG_DIR/channels/discord/access.json, hook reads ~/.claude/channels/discord/access.json — both happen to exist, so the hook reads the stale legacy copy), a contextNotFrom configured operationally through the bridge's path is invisible to the hook → _blacklist() returns empty → fail-open. Since the hook is explicitly the backstop for the one path an instruction can't force (a raw curl), that's the component you least want failing open. (SUTANDO_WORKSPACE is also the env var dropped for resolution in v0.8/#1440, and ~/.sutando/workspace is the pre-M0 default.) Fix: resolve ACCESS_FILE/ENV_FILE from $CLAUDE_CONFIG_DIR (with the ~/.claude fallback) and WS via the M0 workspace helper, matching the other two components.
[limitation] Serving-state is a single global file, last-write-wins. active-serving-channel.json is set by whichever task-file Read/cat happened most recently. Under interleaved/concurrent task processing the "serving" channel a curl is checked against can be stale — a read while serving B could be evaluated against A's blacklist. Fine as best-effort defense-in-depth (the threat model is the agent's own reads, not an adversary), but it's a heuristic, not an airtight boundary — worth stating that explicitly.
[limitation] The Bash guard only matches channels/<id>/messages. A curl …/channels/<id> (channel metadata), a websocket, or an alternate fetch wrapper slips the regex. It's layered with the gated reader + CLAUDE.md instruction, so it's not the only line — but the hook's raw-curl coverage is narrower than "any channel read."
Posture note: the overall design is fail-open by intent (missing token / unknown serving / malformed access.json → allow); the one fail-closed case (blacklist applies + guild unresolvable) is the right call.
Bottom line: ship-worthy after the hook path-resolution fix — without it the backstop no-ops on exactly the relocated installs the rest of the revamp targets. No merge authority — flagging for @sonichi.
…LAUDE_CONFIG_DIR Addresses the COMMENTED review on #1698 (Sutando-Pro): the hook hardcoded ~/.claude for ACCESS_FILE/ENV_FILE, so on a relocated install ($CLAUDE_CONFIG_DIR set elsewhere) it read a stale/empty access.json — a configured contextNotFrom was invisible to the hook and it silently failed OPEN, exactly the backstop you least want failing open. Resolve _CFG = $CLAUDE_CONFIG_DIR (fallback ~/.claude), matching read_discord_channel.py / discord-bridge's claude_home_path. No behavior change on a default install. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed |
|
@cla-assistant check |
…ot dropped SUTANDO_WORKSPACE (#1698 residual) (#1754) * fix(context-source-guard): resolve workspace via CLAUDE_CONFIG_DIR, not dropped SUTANDO_WORKSPACE (#1698 residual) The context-source-guard hook resolved its workspace with `os.environ.get("SUTANDO_WORKSPACE", "~/.sutando/workspace")`. v0.8/#1440 dropped SUTANDO_WORKSPACE for resolution, and `~/.sutando/workspace` is the pre-M0 location — so on a relocated install the hook read/wrote active-serving-channel.json in the LEGACY workspace while discord-bridge.py (M0 resolve_workspace) used `<repo>/workspace`. The two disagreed → the guard couldn't resolve the serving channel → it silently failed OPEN (the one component you least want failing open), and stranded a stray state file in ~/.sutando/workspace/state/. Same fail-open class as the ACCESS_FILE/ENV_FILE residuals already fixed to _CFG in #1698; WS was the one missed. Fix: derive the workspace from CLAUDE_CONFIG_DIR, whose parent is the workspace (`<workspace>/.claude-sutando` per the workspace contract) and which moves with a config-relocated workspace. No subprocess (this hook fires on every Bash/Read), no __file__ walk (the bundled-symlink anti-pattern documented in src/workspace_default.py), no dropped SUTANDO_WORKSPACE. Test: updated the fixture to point CLAUDE_CONFIG_DIR at the temp workspace (was SUTANDO_WORKSPACE) and added a regression asserting a stray SUTANDO_WORKSPACE no longer redirects resolution. Full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(context-source-guard): reword comment to satisfy workspace-resolution lint The explanatory comment in the prior commit literally contained the legacy ~/.sutando-based path; the CI workspace-resolution lint greps added lines for that pattern (it can't tell code from a comment) and failed the check. Reword to describe it without the literal path. No code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(context-source-guard): nearest-.claude-sutando-ancestor WS resolution + document fallback parity (#1754 review) Addresses qingyun-wu's non-blocking review points on #1754: 1. Walk up to the nearest `.claude-sutando` ANCESTOR instead of an exact-leaf basename match. Survives the floated per-host CLAUDE_CONFIG_DIR subdir (`<workspace>/.claude-sutando/hosts/<host>`) — there the leaf is the hostname and an exact match would wrongly fall through to the fallback. 2. Document that the `~/sutando-workspace` fallback is not arbitrary: it is the canonical last-ditch (workspace_default.default_workspace_dir() / _DEFAULT_SUBPATH), so an unresolvable CLAUDE_CONFIG_DIR fails open to the same place the rest of the system uses, not a phantom dir. Adds test case 8: a per-host CLAUDE_CONFIG_DIR resolves back to <WS> so the blacklist still applies (DENY). Lint --diff clean; full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Adds an information-flow gate to the Discord-state prefetch. A channel can declare, in
access.json, which channels/guilds its replies must not pull context from.This mirrors
allowFrom's per-channel shape but on a different axis:allowFrom= who may send a task in this channel.contextNotFrom= which sources may feed a reply in this channel.How
load_channel_context_blacklist(channel_id)reads the per-channelcontextNotFromset fromaccess.json._prefetch_discord_state_refs(text, origin_channel_id)now skips any referenced channel whose channel-id OR guild-id is in the originating channel'scontextNotFrom. Guild-aware: a single guild id blocks every channel in that guild (including future ones) — no enumeration.Config example
→ a task in
#devcan never pull any channel in that private guild, even if the task text references one.Scope / follow-up
The prefetch currently runs for team/other-tier tasks; extending the gate to cover owner-tier public-channel tasks is a follow-up (noted, not in this PR).
Test
python3 -m py_compile src/discord-bridge.pypasses. Additive field — existing access logic (allowFrom/requireMention) is unchanged; only the new gate readscontextNotFrom.🤖 Generated with Claude Code