XClaw drives coding agents with broad tool access and connects to IM networks, so it sits in a security-sensitive position. We take reports seriously.
Please do not open a public issue for security vulnerabilities.
Report privately via GitHub's private vulnerability reporting ("Report a vulnerability" on the repository's Security tab). Include:
- a description of the issue and its impact,
- steps to reproduce or a proof of concept,
- affected component (
core,app,proto) and version/commit.
We aim to acknowledge reports within a few days and will keep you updated on remediation. Please give us reasonable time to fix the issue before any public disclosure.
XClaw is pre-1.0 and under active development. Only the latest main receives
security fixes until a tagged release line exists.
XClaw's design defends against prompt injection from untrusted group chat
(core/safety/, core/groupctx/). However, the project is pre-1.0 and not yet
hardened for hostile multi-tenant deployment. Be aware of these known,
documented limitations before exposing a bot to untrusted users:
- Agent runs with full tool access. The Claude driver always spawns with
--permission-mode bypassPermissions(a headless invariant — there is no terminal to answer approval prompts). The agent can run arbitrary tools, including Bash. - The session sandbox is not a security boundary.
core/sandbox/gives each session a starting working directory, not a chroot/jail. An agent with Bash can still reach absolute paths outside it. Isolation across spaces relies on running one bot per space with a separatecwdBase. - Token storage. The macOS app stores bot tokens in the Keychain and
injects them into the daemon at runtime over the control bus (
secret.inject); the core holds them in memory only and never writes them to disk. Tokens may still be placed in plaintext in~/.xclaw/config.jsonfor headless/no-GUI deployments — protect that file accordingly. Tokens are also passed into the agent subprocess environment at turn time. - IM wire crypto is constrained by protocol compatibility. The Octo/WuKongIM
connector (
core/im/octo/) reproduces the upstream handshake byte-for-byte (curve25519 DH → MD5-derived AES-128-CBC, IV from the server salt). These primitives are weak by modern standards but are dictated by wire compatibility with the server, not a free design choice; the connector only decrypts server-sent frames. - SSRF validation is config-time.
core/config/validates configured URLs, but does not currently defend against DNS rebinding at request time.
If you find a way to defeat the prompt-injection defenses, escape the sandbox in a way the docs claim is prevented, or leak secrets through events/logs, that is in scope — please report it.
These changes have landed in the four post-v0.1 audit rounds. They are not "new features" — they close concrete vectors operators should know about when choosing what version to deploy:
- Credentialed handshake host-pinning (
core/im/octo/rest.go). The server-returnedws_urlis now validated against the operator-configuredapiUrlbefore the connector dials:wss://required (orws://only whenapiUrlis itself loopback), and the WS hostname must equal the API hostname (case-insensitive). Without this, a compromised or MitM'd octo-server could returnws://attacker/and the WS dialer would accept plaintext + arbitrary host, leaking the bot's IMToken in the CONNECT frame. - Add-bot POST refuses redirects (
desktop/internal/octoapi/octoapi.go). The wizard'sPOST /v1/user/bots(which carries the operator'suk_User API Key as a bearer) now refuses any3xx. Go stripsAuthorizationonly on a cross-host redirect — a same-host or sibling-subdomain302would otherwise leak the key.serverMsgadditionally strips control chars + caps length before the error reaches the UI toast. - Octo-cli download SSRF guard (
desktop/internal/octocli/octocli.go). Replaceshttp.DefaultClientwith a dialer that rejects connections to private/loopback/link-local/CGN ranges. GitHub asset URLs redirect through S3/Fastly; a poisoned DNS or compromised mirror could otherwise redirect to169.254.169.254(cloud metadata) or a private internal address. - Wire-protocol DoS sentinels (
core/im/octo/wire.go). The frame parser now exportsErrUnknownPacketType,ErrVarintTooLong,ErrFrameBodyTooLarge,ErrSocketClosed, and the PKCS7 padding sentinels so downstream operators canerrors.Is-match on the failure mode for metrics/alerting. The 8 MiB body cap + 4-byte varint cap are unchanged; only the error API was firmed up. - Token redaction on child-process output
(
desktop/internal/octocli/octocli.go). Anybf_*/uk_*/sk_*/sk-*/ANTHROPIC_*substring in error output bubbled up fromocto-cliis replaced with<redacted>before logs / UI. Octo-cli doesn't currently echo tokens — this is defense-in-depth against a future regression. - First-write atomicity for SOUL.md/AGENTS.md
(
desktop/internal/configstore/configstore.go). Template scaffolding now usesO_CREATE|O_EXCL|O_WRONLY. The prior Stat-then-write derivation of "first time" was a TOCTOU: an agent that plantedSOUL.mdbetween our Stat and our write would have been silently overwritten. Blanking the field on an existing bot is now a NO-OP rather than a silent delete. - Slug validation in the secrets package
(
desktop/internal/secrets/secrets.go).Set/Get/Deletenow reject anybotIDthat failssafepath.ValidSlug. The slug rule also rejects leading.so a bot id can't collide with dotfiles under~/.xclaw/. Without this fence, a future caller passing an attacker-supplied id like"../other"would have written/read another bot's credential namespace. - OCTO_BOT_ID uniqueness enforced
(
desktop/internal/configstore/configstore.go). Two bots sharing anOCTO_BOT_IDwould share anocto-clidisk profile; deleting one would silently break the other's auth on its next agent spawn. Save now rejects the duplicate at write time. - Atomic config/cron writes with fsync (
core/atomicfile.Write, called by bothdesktop/internal/configstoreandcore/cron).config.jsonandcron.jsonare written viaO_CREATE|O_TRUNC+Sync+Rename, and the.tmpis removed on any failure between write and rename — so a power loss or process crash mid-write leaves either the old file or a fully committed new file, never a half-written one. Limitation: parent-directoryfsyncis omitted (industry-typical for application-level atomic writes); a power loss between rename and the next dirent flush could in principle resurrect the old file. SOUL.md / AGENTS.md scaffolding usesO_CREATE|O_EXCLso an agent-planted file is never overwritten on first save. - Cron task prompts are stored in plaintext at
~/.xclaw/<id>/cron.json(0o600, parent dir0o755). Operator-trusted content tier; same caveat as tokens-in-config above. If you grantcron.createto an authenticated peer, treat the resulting prompts as plaintext-at-rest. - In-flight turn shutdown barrier (
core/im/octo/connector.go+core/cmd/xclawd/*.go). The daemon now waits for everydrainTurnsandsession.sendgoroutine to finish before closing the store on SIGTERM. Previously the deferredst.Close()could fire while a turn was still mid-flush, producing"database is closed"errors that broke resume continuity and lost usage accounting silently. - Disk perm tightening. Per-bot skills/workflows files are now
0o600(they are executable code the agent CLI loads on next spawn); octo-cli download.tmp+.prevrollback are0o700(the prior0o755was world-executable during the brief window between write and rename). - Reproducible builds.
xclawdis cross-compiled with-trimpath -buildvcs=falseso binaries don't embed the operator's$HOME/ module-cache absolute paths or the local VCS-dirty flag. - CI coverage.
govulncheckruns on both thecoreanddesktopmodules now (was core-only);go test -raceruns on both Linux and macOS.
These invariants are load-bearing across the rounds 8–10 hardening work. They're documented here so a future contributor doesn't accidentally weaken any of them.
cron.Manager.OwnerUIDis set from the server-resolved bot owner uid (from the octoregisterresponse), NEVER from a client-supplied body.cron.createis gated on the server-resolved owner uid — the body'suidfield is ignored for authorization; it's only echoed into the task'sFromUIDfor routing.- On
SetOwnerUID(newUID), every persisted task whoseCreatedBy != newUIDis dropped. This covers two scenarios:- In-process owner change (bf_ token rotation while the daemon is up).
- First owner resolve after a daemon restart against tasks that
cron.jsoncarried over from a prior owner. Without this, an operator-handoff or attacker-rotation would silently inherit every prior owner's scheduled prompts.
- Cron prompts are operator-trusted content (only the bot owner can author
them). Stored in plaintext at
~/.xclaw/<id>/cron.json(mode0o600).
- The persona grantor uid is set ONCE at daemon startup from per-bot config
(
onBehalfOf.uid), NEVER mutated at runtime. No code path reloads it without a daemon restart. - OBO v2 fields on inbound messages (
obo_origin_channel_id,obo_respond_as, etc.) are honored ONLY whenm.FromUID == c.persona.UID(the configured grantor is the one relaying). A forged OBO v2 message from any other uid is dropped — the trust comes from the FROM uid, not the body claim. - Cron-fired turns on a persona-clone bot reply
on_behalf_ofthe configured grantor (same identity as live replies). Trust derives from the owner-prune (above), not fromtask.CreatedBy(which is always the bot owner uid in production, not the grantor).
- The reply target for each turn travels with the turn on the per-session
queue (
queuedTurn.tgt).drainTurnsis the SOLE writer ofc.targets[key]and sets it just beforegw.Handleruns. onInboundandEnqueueCronenqueue the turn (with target) and do not touchc.targets[key]directly. This prevents the wrong-recipient delivery race that existed when concurrent inbound + cron on the same sessionKey stomped a shared per-key target slot.
Graceful shutdown sequence in runBot:
cm.Stop()— halts the scheduler loop and waits for the loop goroutine to exit (round 10: prevents a tick from spawning a fire after Stop returned).cm.Wait()— drains any in-flightsafeFiregoroutines.connector.WaitTurns()— setsclosed=true(refuses any further enqueue) then drains everydrainTurnsworker.rtBot.target.turnsWG.Wait()— drains any control-bussession.sendgoroutines.- Return → deferred
st.Close()fires.
A tick or inbound that lands between steps 1 and 3 is refused at
enqueueTurn (sees closed=true). Without this ordering a late cron fire
would write to a closed store — "database is closed" data loss.
- The agent subprocess inherits ONLY the variables on
core/agent.envAllowlist(HOME / PATH / locale / proxy trio / SSL CA bundle pointers /LC_*). Operator env that might carry secrets —AWS_*,GH_TOKEN,OPENAI_API_KEY,SSH_AUTH_SOCK, etc. — is dropped. Specifically NOT inherited:SHELL(dropped round 9),NODE_OPTIONS(dropped round 10 — it's an RCE pass-through via--require=/tmp/evil.js). - Per-bot env (
agent.envin config) flows throughextraand is the supported channel for operator-set variables; operators authoringagent.envare accepting responsibility for whatever they put there.
- Refuses to list or read a fixed set of credential-bearing dotfiles
(
.netrc,.npmrc,.git-credentials,id_rsa*,.pgpass,.my.cnf) even if the agent has copied them into its cwd. - Refuses to descend into a fixed set of credential-bearing dotdirs
(
.aws/.azure/.gcloud/.ssh/.gnupg/.docker/.kube/.helm/.cloudflared/.terraform.d/.cargo/.m2/.gradle/.snowsql/.databricks/.config/.continue/.kaggle). - Never follows symlinks (would let
.claude/skills/<bundle>/escape into the global skill catalog).