Skip to main content

5 posts tagged with "Claude Code"

View all tags

Claude Code Sandbox Diagnostics Lie? Rerun Outside It

Β· 6 min read

While debugging a workspace-mcp local service inside Claude Code, curl and lsof both came back looking perfectly "normal" β€” yet the service stayed unreachable. The later discovery was worse: binaries carrying the quarantine attribute hang forever inside the sandbox, which means every conclusion I'd drawn from sandboxed checks was untrustworthy.

TL;DR​

  • On macOS, the Claude Code sandbox doesn't fail loudly β€” it silently returns plausible-looking empty results: curl connects to nothing without an error, lsof misses real listeners, and binaries with the quarantine attribute hang forever (0 CPU, no output).
  • When diagnosing local services, negative results inside the sandbox (no listener, no process, hung command) are worthless. Rerun the same batch of commands with dangerouslyDisableSandbox to get conclusions you can trust.

Symptoms: a "clean" debug session, all wrong​

The contradiction: every check "returned normally" yet the service stayed down β€” the failure had two layers.

Layer one: regular diagnostics went quietly wrong. The workspace-mcp service was unreachable, so I ran the standard checks inside the session: curl 127.0.0.1:8000 showed no error, lsof -iTCP showed nothing unusual. By experience, none of the loud signals β€” connection refused, no listener β€” appeared. Every command "returned normally." But the service stayed down. I retried inside the sandbox several times; same result every time.

Layer two surfaced later and cut deeper: while looking into Syncthing, its core binary (carrying the com.apple.quarantine attribute) hung forever when executed inside the sandbox β€” the process existed, sat at 0 CPU, and printed nothing at all.

Both layers point at the same essence: diagnostic commands inside the sandbox lie. Errors make you alert; plausible negative results make you comfortable β€” and that's the dangerous form.

Why are Claude Code sandbox diagnostics unreliable on macOS?​

Root cause: the sandbox disguises blocking as normal. It distorts commands in three ways, in increasing order of severity.

Network probes go blind. curl 127.0.0.1:8000 inside the sandbox can't reach the port, but it doesn't throw a connection error either β€” it "returns normally." What you see is not connection refused but a clean empty result, and the wrong conclusion ("nothing is listening") gets written into your notes without a trace. The same applies to the phrasings "connection refused" or "localhost unreachable" β€” inside the sandbox, those symptoms get swallowed.

Process enumeration goes blind. lsof -iTCP inside the sandbox cannot see other processes' sockets on the host, so it prints nothing. The port is listening; you just can't see it. "No listener found" does not mean "nothing is listening," and any "the service isn't running" verdict built on it is wrong.

Binary execution hangs. The subtlest one. When you execute a binary carrying the com.apple.quarantine attribute (the Syncthing core), the sandbox policy stacks with the Gatekeeper check and the process stops dead at 0 CPU β€” no return, no crash, no clue.

The obvious escape hatch is welded shut, too. I tried xattr -dr com.apple.quarantine to strip the attribute; on every app under /Applications it returned EPERM β€” starting with macOS 27, the system locks the quarantine attribute on installed apps. That routine self-help path no longer exists on current macOS.

Fix: rerun the same commands outside the sandbox​

Conclusion first: on macOS, always verify local-service diagnostics (port listeners, processes, Gatekeeper, unified log, quarantined binaries) outside the sandbox.

Sandbox vs. bypassed diagnostics on macOS: three silent failure modes inside, real results after bypassing

The fix is mechanical β€” rerun the same batch of commands with dangerouslyDisableSandbox. Side-by-side:

CommandInside the sandboxOutside the sandbox
syncthing --versionhangs forever, 0 CPUreturns in seconds, normal version output
Port checkempty resultreal listener state
Process checkempty resultreal process list
Unified lognot foundfully queryable

Once outside, the earlier "everything looks fine" illusion collapsed β€” ports, processes and the unified log all came back with real data, and nearly every "negative" check from the first round got overturned.

The rule I now keep: negative results inside the sandbox don't count. This is not "sandbox output is garbage" β€” positive findings (a listener you did see, a process you did find) remain valid. What's broken is the empty results: they cannot distinguish "genuinely absent" from "the sandbox won't let you see." And sandbox-style guardrails distorting commands is a pattern, not a one-off β€” Claude Code's Hook interception falsely blocks normal commands the same way; your criteria need to anchor on execution semantics, not surface output.

Boundaries and caveats​

Caveats

  • Bypass for read-only diagnostics: ports, processes, logs and version checks are low-risk; keep everyday builds and execution inside the sandbox β€” don't bypass globally out of convenience.
  • Untrusted code is the opposite case: the sandbox's isolation value exists precisely for running code you don't trust; that path must not be bypassed.
  • Don't try xattr on macOS 27: it returns EPERM for installed apps, period. Executing outside the sandbox is the faster path.
  • Audit past conclusions: if you previously concluded "service is down" or "port not listening" from sandboxed checks, re-verify them with the sandbox bypassed.

If you're building your own tooling around local MCP services like workspace-mcp, the approach in building a custom MCP toolkit with FastMCP pairs well with this β€” and when you wrap diagnostic commands as MCP tools, keep the sandbox layer in mind.

FAQ​

curl to 127.0.0.1 inside the Claude Code sandbox shows no error but never connects β€” is my service down?​

Probably not β€” the sandbox disguises blocking as clean empty results. On macOS, curl inside the sandbox fails to connect without an error, lsof returns nothing while the port is actually listening, and binaries with the quarantine attribute can hang forever (0 CPU, no message). Bottom line: never trust negative results inside the sandbox; rerun the same commands with dangerouslyDisableSandbox before concluding anything.

What does Claude Code sandbox mode silently block on macOS?​

Three things observed in practice: network probes (curl to localhost fails silently), process enumeration (lsof -iTCP can't see other processes' sockets), and binary execution (a binary with the com.apple.quarantine attribute, like the Syncthing core, hangs at 0 CPU with no error). You can't strip the quarantine attribute either β€” since macOS 27, xattr -dr returns EPERM for installed apps. These binaries only run outside the sandbox.

How do I safely bypass the Claude Code sandbox for local diagnostics?​

Rerun the same diagnostic commands with dangerouslyDisableSandbox. In my run everything came back real: syncthing --version returned in seconds, and ports, processes and the unified log were all visible. Bottom line: bypass for read-only diagnostics, keep everyday builds and execution inside the sandbox.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Claude Code Hooks Overblocking? Gate on Real SQL Writes

Β· 10 min read

While configuring a Claude Code Bash hook to stop the AI from writing to a production database, pm2 restart, grep, and Python debug commands got blocked one after another β€” every dangerous SQL was caught, but half of the legitimate ops commands were caught too. This is commonly discussed as hook overblocking or false positives. This post walks through the full narrowing from "keyword matching" to "execution semantics".

Encountered this while operating the production servers behind CCLee Server Sentinel β€” managed Linux server monitoring across performance, availability, security and backups, with alerts merged into conclusions plus remediation guidance.

TL;DR​

Using "command text contains a write keyword" as the hook criteria is guaranteed to overblock: sys.path.insert, pm2 --update-env, and grep 'UPDATE …' all contain whole-word keywords, and \b boundaries save none of them. Narrow the criteria to three conditions β€” ssh prefix to the prod host + psql in the command + word-boundary keyword β€” and 8 classes of false positives drop to zero. Meanwhile, define the legit write path as file piping (keywords never enter the command line), which by construction never triggers the guard. Finally, turn the criteria table into 22 regression cases: change criteria by changing tests first.

Symptom: pm2 restart blocked, ops commands caught in the net​

On day one, the v1 blocking hook caught pm2 restart, remote grep, and Python debug commands β€” none of the blocked commands actually wrote a database.

Some context: our workflow lets Claude Code connect to the production database for read-only troubleshooting β€” locate the trace in the logs table first, form a hypothesis, then read code. That path depends on one premise: INSERT / UPDATE / DROP writes must never be issued directly by the AI. The rule went into CLAUDE.md first, but rules are advisory; models forget under long-session pressure. So we added a mechanical layer: a PreToolUse hook that blocks (exit code 2) any ssh-to-production command whose text contains a write keyword.

The false positives piled up fast. All of these were blocked; none of them writes a database:

# Python path tweak inside a container (sys.path.insert contains "insert")
ssh prod "docker exec app python3 -c 'import sys; sys.path.insert(0, \"/app/lib\")'"

# pm2 rolling env reload (--update-env contains "update")
ssh prod "pm2 restart api --update-env"

# Service restart (pm2 delete contains "delete")
ssh prod "cd /app && pm2 delete api 2>/dev/null; pm2 start ecosystem.config.js && pm2 save"

# Searching code remotely (grep's argument contains "UPDATE")
ssh prod "grep -rn 'UPDATE api_keys' /app/server/"

# Counting errors in logs
ssh prod "tail -5 error.log | grep -c UPDATE"

# pandas inside a container (df.update contains "update")
ssh prod "docker exec airflow python3 -c 'df.update(other)'"

Every blocked command meant Claude had to stop and reroute, breaking the troubleshooting chain. The false-positive list grew to 8 classes (the regression suite keeps 6 representative samples) β€” v1 criteria declared failed.

Root cause: keyword matching hits text, not execution semantics​

Word boundaries cannot fix shell-command false positives β€” v1's regex already had \b, and the false positives happened anyway:

grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'

Word boundaries cannot fix shell-command false positives, and the reason is counter-intuitive: punctuation in code syntax is itself a word boundary. The . in sys.path.insert, the - in --update-env, the . and () in df.update() are all non-word characters to the regex engine, so insert and update count as "whole words" in every one of them. v1's boundaries prevented zero false positives.

One level deeper, the criteria was anchored on the wrong thing: a keyword appearing in the command text does not mean the command executes that keyword's semantics. grep 'UPDATE api_keys' means "search", not "update"; pm2 --update-env means "restart", not "modify data". No text-level trick (boundaries, casing, context windows) can tell them apart, because the discriminating information is not in the text β€” it is in the executor. What is actually dangerous is "psql receives a write statement", not "the word UPDATE appears somewhere".

So the fix is not finer matching but a different anchor: execution semantics. A command poses a database-write risk only if three things hold at once: it is an ssh to the production host, it actually invokes psql, and the text psql will execute contains a write keyword.

The Claude Code hook decision flow: three conditions, all required​

The flow below shows the gate: three conditions in series, every "no" branch allows, only three "yes" results block.

bash-guard decision flow: ssh prod prefix, psql presence, and word-boundary write keyword must all hold before blocking; SQL via file piping passes by construction

The entire v1-to-v2 diff is one line β€” a psql condition added before the keyword check:

# v1: keyword only (deprecated)
if echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then

# v2: psql presence + keyword, checked together
if echo "$CMD" | grep -qi 'psql' && echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then

The full hook (17 lines; replace prod with your production host's ssh alias):

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
[ "$TOOL" != "Bash" ] && exit 0

CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -qi '^ssh prod'; then
if echo "$CMD" | grep -qi 'psql' && echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then
echo "Blocked: psql inline write keyword detected: $CMD" >&2
exit 2
fi
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"read-only ssh command"}}'
exit 0
fi

exit 0

Register it in settings.json, matcher limited to the Bash tool, timeout 5 seconds so a hung script cannot stall the session:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/bash-guard.sh", "timeout": 5 }
]
}
]
}
}

Precise blocking is half the job β€” writes still need a path. Schema changes and data fixes are real work, and they should not die because a hook exists. Our legit path is file piping: SQL goes to disk, stdin feeds psql, and write keywords never enter the command line:

cat x.sql | ssh prod "docker exec -i db psql -U app -d appdb"
# or
ssh prod "docker exec -i db psql -U app -d appdb" < x.sql

A pleasant side effect: the legit path never triggers the hook, no whitelist or exemptions needed. Neither side of the pipe (cat, or ssh … psql without -c) contains a keyword β€” criteria and legit path are structurally disjoint rather than exception-enumerated. The rule worth writing down is: what is forbidden is not "writing the database" but "inlining keywords into the command line" β€” team convention and hook criteria now say the same sentence.

The criteria table is the test: 22 regression cases lock the contract​

A hook encodes criteria; criteria need regression tests. We wrote the criteria table directly as a test table β€” one case per row: command, expected outcome (2 = block, 0 = allow). Running it only verifies; it does not negotiate:

run_case 2 'inline INSERT'   $'ssh prod "docker exec -i db psql -c \'INSERT INTO customers ...\'"'
run_case 0 'pm2 restart --update-env' $'ssh prod "pm2 restart api --update-env"'
run_case 0 'remote grep keyword' $'ssh prod "grep -rn \'UPDATE api_keys\' /app/server/"'
run_case 0 'file pipe cat|' $'cat /tmp/x.sql | ssh prod "docker exec -i db psql"'
run_case 0 'cd && prefix bypass (permission layer backstop)' $'cd /x && ssh prod "psql -c \'DROP TABLE t\'"'
run_case 2 'read-only query with literal keyword (residual)' $'ssh prod "psql -c \'SELECT body FROM logs WHERE body LIKE \\'%UPDATE%\\'\'"'

The 22 cases split into four groups: 7 true blocks, 6 v1 false-positive fixes, 6 read-only and legit-path, 3 known residuals. A comment at the top of the test file reads: "the expectation column is the confirmed contract β€” execution only verifies, never negotiates". Criteria changes must edit the expectation column first, then pass the suite.

The suite repaid itself quickly. The heredoc policy was once recorded as "blocked as well", then reverted the same day β€” and the revert rationale is now unrecoverable. Documentation-based memory forgets; the criteria now has an arbiter: a local heredoc to disk (cat > x.sql <<'EOF', starts with cat, never enters the ssh prefix gate) is allowed; a remote heredoc (ssh prod "… psql …" <<'EOF', full SQL in the command line) is blocked. Both cases sit in the test table β€” run it and the answer is there.

Residual risks: what this line of defense does not catch​

Note

Hook blocking is one layer of defense in depth, not absolute safety. These residuals are known and accepted; evaluate them against your own scenario:

  • cd x && ssh prod "…" prefix bypass: the criteria anchors on the ^ssh prefix, so a command starting with cd slips through. The backstop for this is not the hook β€” Claude Code still asks for permission confirmation on commands outside the allowlist, keeping a human in the loop.
  • Read-only queries with literal keywords still blocked: SELECT body FROM logs WHERE body LIKE '%UPDATE%' gets blocked. The rate is negligible and the failure direction is safe (prefer blocking), so we accept it.
  • psql substring false positives: a path containing "psql" plus a keyword trips the guard, e.g. grep UPDATE /tmp/psql-dump.log.
  • pg_restore not in the enumeration: restores go through a tool, not SQL keywords, so ssh prod "pg_restore -d appdb snap.sql" passes. The enumeration covers SQL write keywords; tool-level dangerous commands need separate coverage.

Why a hook instead of just a CLAUDE.md rule? They are not substitutes: rules are the advisory layer β€” models forget; hooks are the mechanical layer β€” they block even when the model forgets; permission prompts are the human layer β€” they backstop what the hook misses. Each layer covers a different failure mode; no single layer is enough. If you remember one sentence about the criteria: block execution semantics, not text presence.

This is our second Claude Code engineering post; the first was VS Code panel 500s and GLM multi-session rejection β€” both looked like random failures, and in both the root cause lived in the mechanics, not the model.

FAQ​

How do I add a Claude Code hook to block Bash commands?​

Register a PreToolUse command hook with matcher Bash in settings.json, pointing to your script with timeout: 5. The script reads tool_name and tool_input.command from stdin; exit 2 blocks the call and feeds stderr back to the model, exit 0 allows it.

What are best practices for writing Claude Code hooks?​

Anchor the guard on execution semantics, not text presence: this guard fires only when three conditions hold together β€” an ssh prefix to the prod host, psql in the command, and word-boundary write keywords. Keep a regression table (22 cases here) so every criteria change is verified, and document the residual risks you accept.

How do Claude Code PreToolUse hooks work?​

Before a Bash call executes, the hook receives the full command as JSON and answers with an exit code: exit 2 blocks the call and sends stderr back to the model as the reason; exit 0 allows it. A bare exit 0 with no output passes silently.

Why is my Claude Code hook blocking harmless commands?​

Because keyword matching hits text, not semantics: sys.path.insert, --update-env and grep 'UPDATE' all contain whole-word keywords, and word boundaries cannot help since punctuation in code is itself a boundary. v1 blocked 8 classes of legitimate commands this way; adding the psql condition cleared all of them.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Fix WSL2 Cannot Access Windows Host Proxy β€” Three Invisible Pitfalls

Β· 5 min read

Encountered this issue while using AI coding tools (Claude Code, Roo) in WSL2 that need to access APIs through the Windows host proxy. After fixing the firewall, encountered two more hidden pitfalls. Documenting all root causes and solutions.

TL;DR​

WSL2's vEthernet (WSL) virtual NIC is created on every launch. Windows Firewall cannot assign a Network Profile to it, so all inbound rules are ineffective (EnforcementStatus: NotApplicable). Don't add rules β€” disable the firewall on that interface directly:

# Run in Windows PowerShell (Administrator)
Set-NetFirewallProfile -DisabledInterfaceAliases "vEthernet (WSL)"

Additionally, after fixing the firewall, you may encounter two more pitfalls:

  1. Dynamic IP Issue: Host IP changes after WSL/Windows restart
  2. Config Cache Issue: Stale API keys in ~/.claude.json and ~/.claude/settings.json cause auth conflicts

Save 70% of Your AI Coding Plan Quota by Replacing ZhiPu Search with Tavily MCP

Β· 3 min read

When using ZhiPu GLM Coding Plan with Claude Code, I noticed my monthly quota draining faster than expected. The culprit? Built-in MCP tools β€” web-search-prime and web-reader β€” were consuming coding conversation quota for every web search and page fetch.

TL;DR​

Replace ZhiPu's built-in web-search-prime and web-reader MCP services with Tavily MCP. Result: free 1000 searches/month with zero impact on coding quota.