Help us improve
Share bugs, ideas, or general feedback.
From agent
Manages per-channel scope status, opt-in, and audit for ClawCode's access governance layer. Handles shadow/enforce modes and owner escape hatch.
npx claudepluginhub crisandrews/clawcode --plugin agentHow this skill is triggered — by the user, by Claude, or both
Slash command
/agent:scope <status|enable|disable|wizard|test|audit> [args...]<status|enable|disable|wizard|test|audit> [args...]The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Channel-scope is the per-channel opt-in compatibility layer between ClawCode and messaging plugins that publish their own access governance (today: claude-whatsapp's `historyScope`). When a channel is *armed* (mode != off, governance resolvable), ClawCode's memory surfaces honor the upstream plugin's access rules; when a channel is `mode: off` (the default), behavior is identical to having no s...
Provides production-ready TypeScript starters for Claude Code channel servers: CI webhook receiver for GitHub Actions, mobile approval relay, Discord/Telegram bridge, local fakechat dev profile. Includes sender allowlists and security hardening.
Interacts with Channel Talk workspaces via TypeScript CLI: send messages to user chats/groups, read chats, manage groups/bots using API credentials. For support automation and CI/CD.
Guides setup of messaging channels (WhatsApp, Telegram, Discord, iMessage, Slack) for external access to Claude Code agents via plugin installs. Shows exact commands, prerequisites, and relaunch steps.
Share bugs, ideas, or general feedback.
Channel-scope is the per-channel opt-in compatibility layer between ClawCode and messaging plugins that publish their own access governance (today: claude-whatsapp's historyScope). When a channel is armed (mode != off, governance resolvable), ClawCode's memory surfaces honor the upstream plugin's access rules; when a channel is mode: off (the default), behavior is identical to having no scope layer.
This is an OPTIONAL feature — see docs/channel-scope-compat.md. Enforcement covers memory_search, memory_get, memory_context, the QMD path, voice_transcribe, dream promote routing, and chat_inbox_read. Per-chat semantics flow through (a) a synthetic per-chat indexer over the upstream messages.db which produces per-chat chunks, and (b) a cross-plugin request envelope contract — claude-whatsapp embeds a requestEnvelopeToken in each inbound notification, and ClawCode resolves the token to a chat/sender binding for the current MCP call. Owner unlock (declarative identity = "owner" + out-of-band trust file) remains the always-available escape hatch; without an envelope (or with an invalid/expired one) under mode = enforce, calls fall through to guest [].
Talk to the user in the language they've been using on this turn — never default to a hard-coded language.
/agent:scope status confirms the adapter sees access.json./agent:scope wizard walks through the choices, or use the one-liner /agent:scope enable whatsapp shadow (or enforce)./agent:scope disable whatsapp./agent:scope audit.The first word in $ARGS selects the path:
status → show every configured channel's runtime stateenable <channel> [shadow|enforce] → set config.scope.<channel>.modedisable <channel> → set mode to offwizard → interactive REPL flow via AskUserQuestiontest <chatId> → dry-run probe against the adapter (see Step 6)audit → re-run mcp__clawcode__agent_doctor and surface only the scope-* rowsIf $ARGS is empty or the subcommand is unknown, default to status.
statusmcp__clawcode__agent_config(action='get') and parse the scope block.scope, display:
mode (off / shadow / enforce)identity (auto / owner / guest)background.identity (deny / system-owner)execGate.mode (off / shadow / enforce), execGate.policy (denylist / allowlist), and execGate.tools count (or "defaults" when omitted)accessJsonPath, cwdExactMatchOnly<channel>-owner exists? (yes/no) — gates read scope owner unlock<channel>-exec exists? (yes/no) — gates execGate "trust this machine" pathmcp__clawcode__agent_doctor(action='check') and surface the scope rows (scope-pre-enforce-audit, scope-bypasses, scope-quarantine-pending, scope-execgate-status, scope-execgate-shadow-events).If scope is absent in config, say so explicitly and recommend /agent:scope wizard.
enable <channel> [mode]All scope-tree writes are refused by mcp__clawcode__agent_config(action='set') (any key starting with scope). The agent cannot silently elevate or relax the policy; every scope key goes through Bash, which surfaces a permission prompt to the user.
For enable <channel> <mode> the single Bash call covers ALL scope. keys. Substitute BOTH <channel> (validated against the shipped enum {whatsapp, telegram, discord, imessage, webchat}) AND <mode> ('shadow' or 'enforce'; default 'shadow' when omitted) from $ARGS. Refuse any other literal for either parameter — only those values are valid for enable. cwdExactMatchOnly preserves the prior value if the user had it true:
Bash('node -e "const fs=require(\"fs\"),p=\"agent-config.json\";const c=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,\"utf-8\")):{};c.scope=c.scope||{};const ch=\"<channel>\";const cur=c.scope[ch]||{};c.scope[ch]=Object.assign({},cur,{mode:\"<mode>\",identity:\"auto\",accessJsonPath:cur.accessJsonPath||\"auto\",cwdExactMatchOnly:cur.cwdExactMatchOnly===true,background:Object.assign({},cur.background,{identity:\"deny\"})});fs.writeFileSync(p,JSON.stringify(c,null,2));console.log(\"wrote\",p);"')
The Bash call surfaces a permission prompt to the user — that's intentional. Default mode when omitted: shadow. Confirm by re-running status.
Tell the user: "Run /mcp reconnect clawcode for changes to take effect."
disable <channel>First validate <channel> is one of the shipped channel names (whatsapp, telegram, discord, imessage, webchat). Reject any other value with an error message — this defends against future channel IDs that might contain shell metacharacters. Currently shipped names are alphanumeric and safe.
scope.<channel>.mode AND scope.<channel>.execGate.mode are both on the security-sensitive blocklist, so this also goes through Bash. A disable resets BOTH read scope AND execGate to off (the user expected "turn the channel off" — they don't expect read scope to flip but exec scope to remain active):
Bash('node -e "const fs=require(\"fs\"),p=\"agent-config.json\";const c=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,\"utf-8\")):{};c.scope=c.scope||{};const cur=c.scope.<channel>||{};c.scope.<channel>=Object.assign({},cur,{mode:\"off\",execGate:{mode:\"off\"}});fs.writeFileSync(p,JSON.stringify(c,null,2));console.log(\"disabled\");"')
Also remove BOTH trust files (read-scope owner trust AND exec trust) for THIS workspace. As of 1.7.0 the trust files live under a per-workspace fingerprint subdir; the fingerprint is computed by the bridge script scripts/print-workspace-fingerprint.mjs so Bash and TS agree byte-exact:
Bash('set -euo pipefail; [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || { echo "CLAUDE_PLUGIN_ROOT unset"; exit 1; }; TS_HASH=$(node "$CLAUDE_PLUGIN_ROOT/node_modules/tsx/dist/cli.mjs" "$CLAUDE_PLUGIN_ROOT/scripts/print-workspace-fingerprint.mjs" "$PWD"); [[ "$TS_HASH" =~ ^[0-9a-f]{32}$ ]] || { echo "bad TS_HASH=$TS_HASH"; exit 1; }; rm -f -- "$HOME/.claude/agent/scope-trust/$TS_HASH/<channel>-owner" "$HOME/.claude/agent/scope-trust/$TS_HASH/<channel>-exec"')
so both unlocks are dropped along with the mode flip. Both Bash calls surface a permission prompt — intentional: turning scope off and dropping trust are user-visible state changes. Cross-workspace isolation: this only removes THIS workspace's trust; other workspaces are untouched.
wizard (interactive)Load AskUserQuestion via ToolSearch(query='select:AskUserQuestion') first.
Then walk the user through the choices, ONE question per AskUserQuestion call. Sample flow (substitute the user's language):
Which channel?
whatsappagent_doctor reports installed+authenticated.)Mode?
¿Sos el owner de este WhatsApp? (owner-unlock primitive — two-factor: identity = "owner" config + out-of-band trust file)
identity: owner AND step 6 will create a per-workspace trust file (as of 1.7.0) under ~/.claude/agent/scope-trust/<workspace-fingerprint>/whatsapp-owner via Bash. Both writes are required: config alone (which the agent can write) does NOT unlock — the trust file (a separate Bash call the user approves) is what makes the unlock real. The trust is scoped to THIS workspace; granting in workspace A does NOT unlock workspace B. This closes the prompt-injection escalation surface where an agent could otherwise write a config and simulate being the owner.identity: auto (default; user still has to set WHATSAPP_OWNER_BYPASS=1 env if they want unlock, or pick "Sí" later)identity: guest (NO trust file needed — guest is a deny posture, no escalation surface)Background lane (dreams / indexer)?
background.identity: deny (NO trust file)background.identity: system-owner AND step 6 will create the trust file just like the foreground owner case. Background system-owner without trust file silently degrades to deny in resolveAllowed, so the wizard MUST touch the file or the user gets surprising "no dream coverage" behavior.4a. ¿Activar gate de ejecución? (execution gate — separate from read scope; blocks destructive tools when the current turn was triggered by a non-owner inbound)
execGate.mode: shadow initially (will collect would-block events for review)execGate.mode: enforceexecGate.mode: off4b. ¿Qué política? (only if execGate mode is shadow/enforce)
execGate.policy: denylist, execGate.tools omitted (defaults applied)execGate.policy: allowlist, execGate.tools omitted (defaults applied)4c. ¿Confiar este equipo para ejecutar tools cuando un no-owner mensaje vino del canal armado? (only if execGate mode is shadow/enforce — this creates the <channel>-exec trust file, separate from the <channel>-owner read-scope trust file)
~/.claude/agent/scope-trust/<workspace-fingerprint>/<channel>-exec via Bash. The exec-trust file is SEPARATE from the read-scope owner trust file (<channel>-owner) — having one does NOT imply the other. This is intentional: a user can read their own chats without granting the agent the right to run shell commands from non-owner-triggered turns. The trust is scoped to THIS workspace; if you have execGate enforce in multiple workspaces, you must opt-in to exec-trust in each one independently.Confirmation preview: show a summary of the writes that will happen, AND a count of currently-visible channel chunks that would be filtered out under the chosen mode.
To compute the count, call:
Bash('node -e "const m=require(\"./lib/memory-db.ts\");const db=new m.MemoryDB(process.cwd(),[],{ headless:true,quietBoot:true});const n=db.countSyntheticChunksForChannel(\"whatsapp\");db.close();console.log(\"PREVIEW_COUNT=\"+n);"')
Show the user: "Under the chosen mode, approximately N chunks from this channel would be filtered for non-owner queries (owner mode sees them all)."
This is a preview — exact filtering depends on per-chat allowlists and is computed at query time.
Bash (a node -e one-liner that updates agent-config.json) because agent_config(action='set') refuses scope keys by design. The user gets one Bash permission prompt for the JSON edit.owner OR background system-owner was chosen, an additional Bash('mkdir -p && touch && chmod 600') runs to create the trust file. Second permission prompt.identity: owner o background.identity: system-owner. Las dos confirmaciones (config + trust file) son intencionales: cierran el agujero donde un agente prompt-inyectado podía escribir la config y simular ser owner."AskUserQuestion:
Bash config edit + (conditionally) the trust-file touch + close with statusApply: execute the calls in this order:
Single consolidated config write through Bash (because agent_config(action='set') refuses scope.* keys). Substitute <channel>, <mode>, <identity>, <bg>, <egMode>, <egPolicy> from the wizard answers:
Bash('node -e "const fs=require(\"fs\"),p=\"agent-config.json\";const c=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,\"utf-8\")):{};c.scope=c.scope||{};const ch=\"<channel>\";const cur=c.scope[ch]||{};const bg=Object.assign({},cur.background,{identity:\"<bg>\"});const next=Object.assign({},cur,{mode:\"<mode>\",identity:\"<identity>\",accessJsonPath:cur.accessJsonPath||\"auto\",cwdExactMatchOnly:cur.cwdExactMatchOnly===true,background:bg});const eg=\"<egMode>\";next.execGate=eg===\"off\"?{mode:\"off\"}:{mode:eg,policy:\"<egPolicy>\"};c.scope[ch]=next;fs.writeFileSync(p,JSON.stringify(c,null,2));console.log(\"wrote\",p);"')
Notes:
cwdExactMatchOnly preserves the prior value if the user had it true — never silently flipped to false.execGate.tools is intentionally OMITTED so the resolver applies defaults (DEFAULT_DENYLIST_TOOLS / DEFAULT_ALLOWLIST_TOOLS from lib/scope/exec-gate.ts). If the user picked custom tools in a future wizard branch, set next.execGate.tools = ["…"] accordingly.execGate.mode = "off" is ALWAYS written so /agent:scope status surfaces the explicit "currently off" state and a later wizard run can detect intent.If identity = "owner" OR background.identity = "system-owner", create the per-workspace owner trust file. As of 1.7.0 trust files live under a fingerprint subdir keyed by realpath(workspaceRoot) so trust in workspace A does NOT unlock workspace B. The bridge script computes the same hex the runtime uses — no Bash-side crypto reimplementation:
Bash('set -euo pipefail; [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || { echo "CLAUDE_PLUGIN_ROOT unset"; exit 1; }; TS_HASH=$(node "$CLAUDE_PLUGIN_ROOT/node_modules/tsx/dist/cli.mjs" "$CLAUDE_PLUGIN_ROOT/scripts/print-workspace-fingerprint.mjs" "$PWD"); [[ "$TS_HASH" =~ ^[0-9a-f]{32}$ ]] || { echo "bad TS_HASH=$TS_HASH"; exit 1; }; umask 077; DIR="$HOME/.claude/agent/scope-trust/$TS_HASH"; mkdir -p -- "$DIR"; chmod 700 -- "$DIR"; touch -- "$DIR/<channel>-owner"; chmod 600 -- "$DIR/<channel>-owner"; echo "trust file created: $DIR/<channel>-owner"')
If user opted to trust the machine for execGate (step 4c "Sí"), create the per-workspace exec trust file. SEPARATE trust file from the read-scope owner trust; agent cannot create either via MCP — both go through Bash + user permission prompt. The doctor row scope-execgate-status reflects trust validity (mode/uid/symlink checks), not just file presence:
Bash('set -euo pipefail; [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || { echo "CLAUDE_PLUGIN_ROOT unset"; exit 1; }; TS_HASH=$(node "$CLAUDE_PLUGIN_ROOT/node_modules/tsx/dist/cli.mjs" "$CLAUDE_PLUGIN_ROOT/scripts/print-workspace-fingerprint.mjs" "$PWD"); [[ "$TS_HASH" =~ ^[0-9a-f]{32}$ ]] || { echo "bad TS_HASH=$TS_HASH"; exit 1; }; umask 077; DIR="$HOME/.claude/agent/scope-trust/$TS_HASH"; mkdir -p -- "$DIR"; chmod 700 -- "$DIR"; touch -- "$DIR/<channel>-exec"; chmod 600 -- "$DIR/<channel>-exec"; echo "trust file created: $DIR/<channel>-exec"')
After applying, run status to display the new state. Then ALSO surface the first-run banner to the user once, in their language:
Scope active. MCP-level filtering covers
memory_search,memory_get,memory_context,voice_transcribe, dream promote, andchat_inbox_read. It does NOT cover nativeRead,Grep, or direct SQLite reads over channel log files — those bypass scope by design (MCP scope ≠ filesystem sandbox). If you want hard isolation, that lives at the OS / filesystem-permissions layer.
Then also create a once-only marker so future wizard runs in this workspace don't repeat the banner:
Bash('mkdir -p ~/.claude/agent && touch ~/.claude/agent/.scope-banner-shown')
Skip the banner emission if ~/.claude/agent/.scope-banner-shown already exists — the user has already seen it.
Finally remind the user to /mcp reconnect clawcode for changes to take effect.
If invoked outside an interactive REPL session, abort with: "The wizard requires an interactive REPL — use /agent:scope enable <channel> [shadow|enforce] instead."
test <chatId> (read-scope dry-run) and test exec <senderJid> <toolName> (execGate dry-run)Show what a given chat-id (or sender+tool combo) would resolve to under the current scope config — read-only, no writes.
Read-scope dry-run (test <chatId>):
Parse <chatId> from $ARGS. If missing, prompt the user for one.
Call mcp__clawcode__agent_config(action='get') and pull scope.<channel> (default whatsapp).
Use Bash to run a dry-run probe:
Bash('node -e "const r=require(\"./lib/scope/runtime.ts\");const c=require(\"./lib/config.ts\");const ctx=require(\"./lib/scope/context.ts\");const cfg=c.loadConfig(process.cwd());const rt=r.detectScopeRuntime(cfg,process.cwd());const adapter=r.getScopeAdapter(\"whatsapp\");if(!adapter){console.log(JSON.stringify({error:\"adapter not armed\",mode:cfg.scope?.whatsapp?.mode}));process.exit(0);}const c1=ctx.makeForegroundContext(\"dry-run\");const allowed=adapter.allowedChatIds(c1);const chatIdToTest=process.argv[1]||\"unknown\";const visible=allowed===null||allowed.includes(chatIdToTest);console.log(JSON.stringify({chatId:chatIdToTest,mode:rt.channels.whatsapp?.mode,armed:rt.channels.whatsapp?.armed,allowedShape:allowed===null?\"unrestricted (owner)\":allowed.length===0?\"deny-all (guest)\":\"partial (\"+allowed.length+\" chats)\",visible}));" "<chatId>"')
Surface the JSON output to the user in their language. Translate visible: true to "this chat WOULD be visible to the agent under the current scope" and visible: false to "this chat would be hidden". Add a line: "This is a dry-run — no config or memory state changed."
ExecGate dry-run (test exec <senderJid> <toolName>):
Show what the execution gate would decide for a given sender + tool combo. Useful for verifying allowlist/denylist semantics + hard-deny (Bash/Task) + trust file unlock before flipping shadow → enforce.
Parse <senderJid> <toolName> from $ARGS. If missing, prompt the user for both.
Invoke the real pure-function resolver (lib/scope/exec-gate.ts:resolve) with an injected envelope reader that returns a synthetic non-owner envelope. The resolver honors the actual trust-file check, hard-deny set (Bash, Task), and protected-paths logic.
IMPORTANT: this snippet calls into .ts files directly so it MUST run under tsx, but invoking via npx tsx from the user's workspace cwd is unreliable (npx searches the local node_modules/.bin first and fetches from the registry on miss). Use the plugin-local tsx CLI explicitly via node "$CLAUDE_PLUGIN_ROOT/node_modules/tsx/dist/cli.mjs":
Bash('node "$CLAUDE_PLUGIN_ROOT/node_modules/tsx/dist/cli.mjs" -e "
const eg = require("./lib/scope/exec-gate.ts"); const c = require("./lib/config.ts"); const r = require("./lib/scope/runtime.ts"); const wa = require("./lib/scope/whatsapp.ts"); const path = require("path");
const cfg = c.loadConfig(process.cwd()); const senderJid = process.argv[1]; const toolName = process.argv[2]; const channel = "whatsapp";
const execGate = eg.execGateConfigForChannel(cfg.scope, channel); const channelDir = r.resolveWhatsappChannelDir(cfg, process.cwd()) || ""; const ownerJids = []; if (channelDir) { try { const access = wa.loadAccess(path.join(channelDir, "access.json"), new Map()); if (access.resolvable && access.access) ownerJids.push(...(access.access.ownerJids || [])); } catch {} }
// Inject a fake envelope reader so the resolver "sees" the supplied senderId // as an in-window non-owner inbound. recordShadow is a no-op for dry-run. const fakeReader = { load: (cd, token, now) => ({ version: 1, token, chatId: senderJid, senderId: senderJid, ts: now, expiresAt: now + 60000 }) }; const fakeFs = { readdirSync: () => ["dryrun.json"], statSync: () => ({ isFile: () => true, mtimeMs: Date.now() }) };
const decision = eg.resolve({ toolName, toolInput: toolName === "Bash" ? { command: "dryrun" } : {}, pluginRoot: process.cwd(), workspaceRoot: process.cwd(), memoryDir: path.join(process.cwd(), "memory"), armed: [{ channel, channelDir: channelDir || "/tmp/dryrun", ownerJids, execGate }], reader: fakeReader, fsImpl: fakeFs, effects: { recordShadow: () => {} } });
console.log(JSON.stringify({ senderJid, toolName, channel, execGateMode: execGate.mode, policy: execGate.policy, ownerJidsKnown: ownerJids.length, isOwner: ownerJids.includes(senderJid), decision: decision.decision, reason: "reason" in decision ? decision.reason : null }, null, 2)); " "" ""')
3. Surface the JSON output. Translate `decision` to plain language (`allow` / `block` / `shadow`). Note: hard-deny tools (`Bash`, `Task`) block regardless of policy. Trust file `<channel>-exec` (if present + valid uid/mode) unlocks any non-owner-induced call. Add: "This is a dry-run — the resolver pure function was invoked but the hook subprocess was NOT spawned. To verify end-to-end including stderr surfacing, send a real test message from `<senderJid>` and observe the agent's response."
### Step 7 — `audit`
Pass-through to `mcp__clawcode__agent_doctor(action='check')`. Filter the doctor card to only the `scope-*` rows and surface them. Reference `docs/channel-scope-compat.md` for what each row means.
## Limitations
- **Owner unlock for WhatsApp**: set `scope.whatsapp.identity = "owner"` via wizard for declarative per-workspace unlock. The wizard also creates the out-of-band trust file `~/.claude/agent/scope-trust/<workspace-fingerprint>/whatsapp-owner` (Bash-prompted; per-workspace as of 1.7.0). Both are required: config alone does NOT unlock. `WHATSAPP_OWNER_BYPASS=1` env is an alternative escape-hatch. Without any of these, foreground calls hit the owner-only ceiling. Trust granted in one workspace does NOT unlock another workspace.
- **Per-chat binding for non-owner senders**: claude-whatsapp publishes a per-inbound `requestEnvelopeToken` in the MCP notification meta. The agent forwards the token to ClawCode memory tools, ClawCode validates the envelope file under `<channel-dir>/.request-envelopes/<token>.json` and emits a per-chat allowlist mirroring upstream `claude-whatsapp/scope.ts:scopedAllowedChats` byte-exact.
- **Residual risks (mirror of `docs/scope-envelope-contract.md` threat model, both kept in sync)**:
- **Same-uid filesystem forge** (architectural, out of scope): any code running as your user can plant or tamper with channel-state files including the request envelope. The uid match check in the reader rules out cross-user tampering but not same-user adversaries. Scope is a privacy/safety layer between MCP tool calls and the agent, NOT a defense against OS-level adversaries already running as you.
- **Token confusion across concurrent inbounds**: if two inbounds arrive within the 60s TTL, the agent holds two valid tokens. A prompt injection in inbound A could induce the agent to forward inbound B's token (leaking B's scope). Bounded by recent-inbound set + TTL; not closeable without per-tool-call authority binding.
- **Within-TTL replay**: same token re-used within 60s by the same agent (required for multi-tool flows like `memory_search` → `memory_get`).
- **Reply-egress taint**: once a snippet reaches the agent, voice/dream output is not taint-tracked.
- **MCP-level filtering ≠ filesystem sandbox**: native `Read`/`Grep`/SQLite over channel logs always bypass scope by design. If hard isolation is required, that lives at the OS layer.
## Reference
- `docs/channel-scope-compat.md` — full design doc + roadmap
- `docs/doctor.md` — `scope-pre-enforce-audit`, `scope-bypasses`, `scope-quarantine-pending`
- Plan: `~/.claude/plans/como-ves-poder-tener-agile-dove.md`