Skip to content

feat(discord-bridge): contextNotFrom gate — bar a channel from pulling context from blacklisted channels/guilds - #1698

Merged
liususan091219 merged 6 commits into
mainfrom
feat/discord-context-blacklist
Jun 20, 2026
Merged

liususan091219 merged 6 commits into
mainfrom
feat/discord-context-blacklist

Conversation

@liususan091219

Copy link
Copy Markdown
Collaborator

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-channel contextNotFrom set from access.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's contextNotFrom. Guild-aware: a single guild id blocks every channel in that guild (including future ones) — no enumeration.

Config example

"groups": {
  "<#dev channel id>": {
    "requireMention": false,
    "allowFrom": ["...user ids..."],
    "contextNotFrom": ["<private guild id>"]
  }
}

→ a task in #dev can 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.py passes. Additive field — existing access logic (allowFrom/requireMention) is unchanged; only the new gate reads contextNotFrom.

🤖 Generated with Claude Code

…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>
liususan091219 added a commit that referenced this pull request Jun 17, 2026
…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>
@liususan091219
liususan091219 force-pushed the feat/discord-context-blacklist branch from 855e428 to 5c75e52 Compare June 17, 2026 23:27
liususan091219 added a commit that referenced this pull request Jun 17, 2026
… + 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>
@liususan091219
liususan091219 force-pushed the feat/discord-context-blacklist branch from 5c75e52 to bef0e70 Compare June 17, 2026 23:33
liususan091219 added a commit that referenced this pull request Jun 17, 2026
… + 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>
@liususan091219
liususan091219 force-pushed the feat/discord-context-blacklist branch 2 times, most recently from 72831dd to ab6c8f8 Compare June 17, 2026 23:54
… + 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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@liususan091219

Copy link
Copy Markdown
Collaborator Author

Pushed cdb0a1a6 addressing the review comment: the context-source-guard hook now resolves ACCESS_FILE/ENV_FILE from $CLAUDE_CONFIG_DIR (fallback ~/.claude), matching read_discord_channel.py/discord-bridge's claude_home_path — so it no longer fails OPEN on a relocated install. No behavior change on a default install.

@liususan091219
liususan091219 requested a review from sonichi June 20, 2026 21:31
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@liususan091219
liususan091219 merged commit 0bc1ef8 into main Jun 20, 2026
3 checks passed
@liususan091219
liususan091219 deleted the feat/discord-context-blacklist branch June 20, 2026 22:56
liususan091219 added a commit that referenced this pull request Jun 26, 2026
…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>
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.

3 participants