Probe: does $.mcp.call reach ws mailbox_recv without a permission prompt; $.tool.call control.

Personal developer environment and local ws workflow workspace.
This repo started as dotfiles and a workstation bootstrap script. It now also contains the local ws and wsflow plugin packages, the native ws-mcp runtime source, and workflow docs for that system.
git clone https://github.com/kang-sw/devenv.git ~/devenv
cd ~/devenv
bash install.sh # idempotent; safe to re-run
bash install.sh update # skip packages & sudo, refresh config and symlinks
Neovim 0.10+ is required but not installed by the script.
nvim/ Neovim config
shell/ tmux, WezTerm, Starship, zsh, helper scripts
tools/ local Claude session TUIs
agents-plugin/ ws plugin package
agents-plugin-wsflow/ agentless wsflow plugin package
agents-plugin-tool/ native ws MCP runtime and tooling source
ai-docs/ workflow specs, tickets, mental models, references
claude-watch and claude-dash live under tools/.ws plugin: Codex-first workflow skills plus the ws-mcp runtime. Claude Code compatibility uses the agents-plugin/ package metadata; there is no live claude-plugin/ source tree.wsflow plugin: Agentless workflow skills that reuse the shared runtime without ws managed-agent orchestration.agents-plugin-tool/.ai-docs/.Detects macOS, WSL, and Linux. Full mode installs packages and writes config. update mode skips package installation and refreshes dotfiles, symlinks, and local plugin setup.
The active ws plugin package lives in agents-plugin/.
Codex installs use the repository marketplace entries under .agents/plugins/. Claude-compatible marketplace metadata lives under .claude-plugin/.
Local bootstrap registers a Claude-compatible snapshot from agents-plugin/ when Claude Code is available:
claude plugin marketplace add kang-sw/devenv
claude plugin install ws@kang-sw-devenv
The agentless wsflow package lives in agents-plugin-wsflow/ for separate marketplace installation. install.sh does not install wsflow into Claude.
The ws workflow tools are also available inside Pi (@earendil-works/pi-coding-agent) sessions through a bundled extension. Install it straight from this repo with Pi's own git package manager:
pi install git:github.com/kang-sw/devenv@<tag>
Replace <tag> with a published release tag (for example v0.46.8). The <host> segment (github.com) is required — Pi's git spec is git:<host>/<user>/<repo>, and a host-less git:kang-sw/devenv mis-parses kang-sw as the host and fails to resolve.
Pi clones the repo, runs npm install against the repo-root package.json (which declares the extension and its runtime dependencies), and loads the extension. No local build step is required: on first use the extension's launcher downloads the release's ws-mcp binary, verifies its checksum, and registers the ws/* tools automatically.
Personal configuration. Use freely, no warranty implied.
hooks/register.ts 119 lines1import type { Register } from 'claude-code'
2
3// Probe for 261016-research-claude-mod-mailbox-active-push, open question:
4// does $.mcp.call reach the ws server's mailbox_recv with no permission prompt
5// under a prompting mode, and with which server/tool spelling.
6// Inputs come from env-free constants so the probe stays self-contained;
7// the runner script rewrites PROBE_KEY / LOG before each run.
8const PROBE_KEY = 'baggy-disparity-external'
9const LOG = '/tmp/mcp-call-probe/log.txt'
10const RECV = 'mailbox_recv'
11const SERVER_SPELLINGS = ['plugin:ws:ws', 'plugin_ws_ws', 'ws', 'plugin:wsflow:wsflow', 'plugin_wsflow_wsflow']
12const CONTROL_TOOL = 'mcp__plugin_ws_ws__mailbox_recv'
13// When true, a tool.check hook allows this plugin's own main-loop calls of the
14// recv tool (next.origin.plugin is the caller) and defers everything else.
15const GRANT_OWN_CALLS = false
16const OWN_PLUGINS = ['mcp-call-probe', 'ws']
17
18function show(v: unknown): string {
19 try {
20 return JSON.stringify(v)
21 } catch {
22 return String(v)
23 }
24}
25
26function errText(err: unknown): string {
27 return err instanceof Error ? `${err.name}: ${err.message}` : show(err)
28}
29
30export const register: Register = (on) => {
31 let done = false
32 const lines: string[] = []
33
34 on('tool.check', { tool: CONTROL_TOOL }, async ($, e, next) => {
35 const origin = next.origin
36 const own = OWN_PLUGINS.includes(origin.plugin) && e.agentId === undefined
37 lines.push(`${new Date().toISOString()} tool.check hook: origin=${show(origin)} tool_use_id=${e.tool_use_id ?? '-'} agentId=${e.agentId ?? '-'} input=${show(e.input)} own=${own}`)
38 if (GRANT_OWN_CALLS && own) {
39 await $.fs.write(LOG, lines.join('\n') + '\n')
40 return { decision: 'allow', reason: 'ws mod drains its own bound inbox' }
41 }
42 const beneath = await next(e)
43 lines.push(`${new Date().toISOString()} tool.check hook: verdict beneath=${show(beneath)}`)
44 await $.fs.write(LOG, lines.join('\n') + '\n')
45 return beneath
46 }).catch(() => ({ decision: 'deny' }))
47
48 // Observer: does a tool.call hook see the model's own call of the recv tool?
49 on('tool.call', { tool: CONTROL_TOOL }, async ($, e, next) => {
50 lines.push(`${new Date().toISOString()} tool.call hook: origin=${show(next.origin)} agentId=${e.agentId ?? '-'} session_key=${show(e.session_key)} format=${show(e.format)} tool_use_id=${show((e as { tool_use_id?: string }).tool_use_id)}`)
51 await $.fs.write(LOG, lines.join('\n') + '\n')
52 const res = await next(e)
53 lines.push(`${new Date().toISOString()} tool.call hook: result=${show(res).slice(0, 300)}`)
54 await $.fs.write(LOG, lines.join('\n') + '\n')
55 return res
56 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
57
58 on('prompt.submit', async ($, e, next) => {
59 if (done) return next(e)
60 done = true
61 const log = async (s: string) => {
62 lines.push(`${new Date().toISOString()} ${s}`)
63 await $.fs.write(LOG, lines.join('\n') + '\n')
64 }
65 try {
66 await log(`version=${show(await $.session.version())}`)
67 } catch (err) {
68 await log(`version error ${errText(err)}`)
69 }
70 try {
71 const tools = (await $.tool.list()).map((t) => t.name).filter((n) => n.includes('mailbox'))
72 await log(`tool.list mailbox tools=${show(tools)}`)
73 } catch (err) {
74 await log(`tool.list error ${errText(err)}`)
75 }
76 // Design sequence: pre-check with $.tool.check, then drain with $.mcp.call.
77 try {
78 const pre = await $.tool.check({ tool: CONTROL_TOOL, input: { session_key: PROBE_KEY, format: 'json' } } as never)
79 await log(`precheck tool.check(${CONTROL_TOOL}) -> ${show(pre)}`)
80 const res = await $.mcp.call('plugin:ws:ws', RECV, { session_key: PROBE_KEY, format: 'json' })
81 await log(`precheck-then mcp.call(plugin:ws:ws, ${RECV}) OK -> ${show(res)}`)
82 } catch (err) {
83 await log(`precheck sequence threw ${errText(err)}`)
84 }
85 const servers = [...SERVER_SPELLINGS]
86 for (const server of ['ws', 'wsflow']) {
87 try {
88 const c = await $.mcp.connect(server)
89 if (c.isConnected && !servers.includes(c.server)) servers.unshift(c.server)
90 await log(`mcp.connect(${server}) -> ${show(c)}`)
91 } catch (err) {
92 await log(`mcp.connect(${server}) threw ${errText(err)}`)
93 }
94 }
95 for (const server of servers) {
96 try {
97 const res = await $.mcp.call(server, RECV, { session_key: PROBE_KEY, format: 'json' })
98 await log(`mcp.call(${server}, ${RECV}) OK -> ${show(res)}`)
99 } catch (err) {
100 await log(`mcp.call(${server}, ${RECV}) threw ${errText(err)}`)
101 }
102 }
103 try {
104 const chk = await $.tool.check({ tool: CONTROL_TOOL, input: { session_key: PROBE_KEY, format: 'json' } } as never)
105 await log(`tool.check(${CONTROL_TOOL}) -> ${show(chk)}`)
106 } catch (err) {
107 await log(`tool.check(${CONTROL_TOOL}) threw ${errText(err)}`)
108 }
109 try {
110 const res = await $.tool.call({ tool: CONTROL_TOOL, session_key: PROBE_KEY, format: 'json' } as never)
111 await log(`tool.call(${CONTROL_TOOL}) -> ${show(res)}`)
112 } catch (err) {
113 await log(`tool.call(${CONTROL_TOOL}) threw ${errText(err)}`)
114 }
115 await log('done')
116 return next(e)
117 })
118}
119