Command Gate
The Command Gate is wick's shell-command approval system for AI agents. Every shell command an agent wants to run goes through a sidecar binary (<app>-gate) that either approves it from a whitelist, blocks it, or asks you in real time via the web UI.
Prerequisite
The gate is part of the Agents subsystem. If you're not running agents, you don't need it.
Why it exists
An agent running as a subprocess can call Bash tools at any time. Without a gate, there's no point at which you can intervene before the command runs.
User: "delete old logs"
Agent: [runs: find /var/log -mtime +30 -delete]
← already executed, nothing stopped itProvider CLIs expose a pre-execution hook — the gate is the binary called by that hook. The hook fires before the tool executes; the gate either allows or denies, and the provider acts accordingly.
Provider hook contracts
| Provider | Hook name | Allow output | Deny output | Exit (deny) |
|---|---|---|---|---|
| Claude (≥ 2.1.138) | PreToolUse | {"hookSpecificOutput":{"permissionDecision":"allow"}} | {"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"..."}} | 0 |
| Codex (0.129+) | PreToolUse | {"permissionDecision":"allow"} | {"permissionDecision":"deny","reason":"..."} | 0 or 2 |
| Gemini | BeforeTool | exit 0 | {"decision":"deny","reason":"..."} | 2 |
The gate uses an adapter per provider (internal/agents/gate/adapter/<provider>/) that translates between the provider-specific stdin/stdout shape and the canonical internal protocol. This isolates contract drift — when Claude changed from exit 2 to exit 0 + JSON in v2.1.138, only the claude adapter changed; everything else was untouched. See command-gate-claude-2.1-fix.md for the full incident write-up.
We do not pass bypass flags (--permission-mode bypassPermissions, --ask-for-approval=never, etc.) when a gate is attached — those flags suppress the hook entirely, defeating the gate. The bypass flag and per-instance gate are mutually exclusive; factory enforces this.
Per-instance gate toggle
Gate is off by default for every provider instance. Turn it on per-instance from the Providers page → Command Gate section.
When you enable it, wick runs a capability probe: it spawns the provider with a force-deny hook (<app>-gate --probe), asks it to touch a sentinel file, and verifies the file was not created. Green = hook honored; red = gate locked off with a "not supported in this version" banner. The probe result is cached for 1 hour (re-run via the Test button).
Master switch
The master gate switch on the Providers page fans out — toggle ON sets every instance's per-instance flag and kicks off background probes; toggle OFF clears all flags. Single source of truth is always the per-instance Hooks["PreToolUse"].Enabled field on disk.
Bypass lock: if the gate's PermissionMode is set to bypass (non-interactive channels), the master switch shows a locked (bypass) badge and refuses toggles. Switch PermissionMode back to on first.
Command gate config
The gate config carries two independent modes:
| Field | What it controls | Values |
|---|---|---|
PermissionMode | Per-tool permission prompts (the PreToolUse hook on each provider) | on (install hook) / bypass (skip — for Slack / HTTP / cron channels where no human can answer) |
AskUserMode | The ask_user MCP tool the agent calls mid-turn (global fallback) | on (route question to the web UI) / off (return a clean error to the LLM so it picks a default instead of hanging) |
The master GateEnabled flag controls only the command gate (the PreToolUse hook path). Turning it off sets PermissionMode: bypass — commands run unguarded. It does not disable ask_user; that tool rides wick's own socket/SSE channel independently of whether the hook is installed.
AskUserMode is the global fallback for ask_user. Per-channel overrides take priority — see AskUser policy below.
Defaults on fresh install: gate on, permission prompts on, ask_user routed to the UI. The legacy bypass_permissions checkbox was retired in v0.13.0 — its value is one-shot migrated to PermissionMode at boot.
Capability badges per provider card
| State | Badge |
|---|---|
PermissionMode: bypass | locked (bypass) |
| Master off | locked |
| Master on + probe in flight | testing… |
| Master on + intent on + verified | enabled ✓ |
| Master on + intent on + unverified | enabled (unverified) |
| Master on + intent off + probe passed | ready |
| Master on + intent off | disabled |
Intercept scope per provider
| Provider | Scope |
|---|---|
| claude | All tools — Bash, Read, Write, Edit, Glob, MCP tools, and any future tools |
| codex | Shell commands only |
| gemini | Untested — adapter shipped, runtime unverified |
Scope is shown as a badge on the Providers card so you know what the gate actually covers.
What gets intercepted (Claude)
The gate uses a catch-all .* matcher, so every tool call routes through the gate:
| Tool type | Gate behavior |
|---|---|
| Bash | Whitelist check → auto-approved check → ask user |
| Read / Write / Edit / Glob | Scope check — if path is within the project default_scope, auto-allow; otherwise ask user |
| wick read-only / info MCP tools | Always allow — gate's own built-in list (see tip below) |
Other MCP tools (e.g. mcp__some-server__action) | Always ask user (no scope to check) |
| Unknown / future tools | Always ask user |
File tools within the project scope are auto-allowed without a popup, so normal agent file operations stay fast. Only out-of-scope file access and non-wick MCP calls prompt you.
wick's own read-only MCP tools are gate-exempt
The gate binary has a built-in always-allow list for wick's read-only discovery and session-housekeeping tools. These never trigger the approval prompt, regardless of whitelist rules:
| Tool | Purpose |
|---|---|
wick_list | List connectors / accounts |
wick_search | Search connectors |
wick_get | Read a connector's schema |
wick_info | Server info |
wick_list_providers | List agent providers |
wick_skill_list | List available skills |
wick_session_info | Read session metadata |
wick_set_title | Update the session sidebar title |
wick_session_workspace | Manage per-session ephemeral connector instances |
ask_user / AskUserQuestion | Route a question to the web UI |
wick_execute and wick_skill_sync are not on this list — they run real connector ops or write files and remain gated. The gate-exempt tools are also guarded server-side (access control, audit log), so the exemption at the gate is a UX shortcut, not a security gap. If you need management ops reviewed at the gate, see Wick Manager → Command gate & management ops.
Approval modes
When a tool call isn't auto-approved, the gate dials a Unix socket and the daemon broadcasts an SSE event. The web UI renders a modal with four choices:
| Button | API value | Scope | Effect on future matching calls |
|---|---|---|---|
| Approve once | approve_once | This request only | Modal again next time |
| Allow this session | approve_session | While the session lives (in-memory) | Auto-approved silently for this session |
| Always allow | approve_always | Persistent (written to spec.json) | Auto-approved across restarts, all sessions |
| Block | block | This request only | Tool call cancelled; modal again next time |
A countdown shows the remaining 25 seconds. If you don't answer, the daemon auto-blocks.
The "Approved commands" panel on the session detail page lists every approve_always rule with a Revoke button. Match key is a hash of tool + cmd; tool names are normalized to canonical (shell → Bash, apply_patch → Edit) so an approve_always from one provider applies across providers.
Architecture
Provider subprocess (claude / codex / gemini)
│
│ provider-specific hook fires (PreToolUse / BeforeTool / …)
▼
<app>-gate --provider=<name> (stateless, short-lived, one per command)
│
├─ stdin: provider-specific hook payload
├─ adapter.Parse → canonical Decision {Tool, Cmd, Cwd, RequestID, Provider}
├─ read shared spec.json
│ └─ AutoApproved hit? → adapter.Emit allow (zero-latency hot path)
│ └─ whitelist rule match? → adapter.Emit allow
│
├─ dial Unix socket: ~/.<app>/agents/gate/gate.sock
├─ send Decision (raw JSON, newline-delimited)
│
▼
Daemon (in main wick process)
│
├─ route by cwd → which session owns this hook payload?
├─ approve_session cache hit? → auto-reply Result{Allow:true}
├─ broadcast SSE event to web UI
│
▼
Web UI modal → user clicks Approve / Reject
│
▼
POST /api/agents/sessions/{id}/approve → daemon
│
▼
Daemon sends Result {Allow, Reason} back through socket
│
▼
Gate: adapter.Emit → provider-specific stdout envelope, exit 0The whole path runs inside the provider's 30-second hook timeout. The daemon's own deadline is 25s, so the gate always exits before the provider gives up.
Canonical gate ↔ daemon protocol
Internal only — never crosses the provider boundary:
// gate → daemon
type Decision struct {
Tool string `json:"tool"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
RequestID string `json:"request_id"`
Provider string `json:"provider"` // audit only
Probe bool `json:"probe,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
// daemon → gate
type Result struct {
Allow bool `json:"allow"`
Reason string `json:"reason,omitempty"`
}Binary decision only — approval scope (once / session / always) lives entirely in the daemon.
File layout
Everything is shared per-app at ~/.<app>/agents/gate/:
~/.<app>/agents/gate/
├── spec.json ← whitelist rules + AutoApproved list (daemon-owned)
├── gate.sock ← Unix domain socket, chmod 0600
└── commands.jsonl ← machine-readable audit trail (multi-stage entries)Plus a per-day human-readable tail log alongside the other wick logs:
~/.<app>/logs/gate-YYYY-MM-DD.logWhy one shared spec/socket/log
Earlier iterations gave each session its own socket directory. Approvals are an app-wide concern (approve_always should mean the same thing across every session) and the daemon routes to the right session by matching the hook's cwd against known project paths. One listener, one spec, one audit log.
commands.jsonl format
Multi-stage trail per invocation, tied together by RequestID:
{"ts":"...","stage":"received","request_id":"r-abc","tool":"Bash","cmd":"git status"}
{"ts":"...","stage":"socket_dial","request_id":"r-abc"}
{"ts":"...","stage":"socket_sent","request_id":"r-abc"}
{"ts":"...","stage":"socket_recv","request_id":"r-abc"}
{"ts":"...","stage":"terminal","request_id":"r-abc","decision":"approve_once","match_key":"..."}Filter by request_id to follow one command end-to-end. The session detail Commands tab filters by project cwd prefix.
Daily tail log
~/.<app>/logs/gate-YYYY-MM-DD.log is a human-readable mirror, one line per stage transition:
2026-05-10T06:36:34Z info cmd=git status status=allowed decision=whitelist
2026-05-10T06:36:40Z info cmd=rm -rf . status=blocked decision=block reason="user blocked"Best-effort — write errors are swallowed so the gate never crashes on logging failure.
Binary resolution
<app> is derived at runtime from the gate executable's filename: strip .exe, strip the -gate suffix. So wick-lab-gate.exe → wick-lab → paths land in ~/.wick-lab/agents/gate/. The main wick binary uses the same chain on its own filename, so they always agree on the directory.
ResolveGateBinary finds the gate sidecar in this order — first hit wins:
- sibling-of-executable —
<app>-gate[.exe]next to the main binary. Primary path when installed viawick build --installer(MSI / .deb / .app ships the sidecar alongside the main binary). - embedded extract —
//go:embed assets/gate-<os>-<arch>unpacked once into a temp location. Backup for portable.exe/ source builds. - PATH —
exec.LookPath("<app>-gate"). Last-ditch.
No environment variables in the chain. WICK_GATE_BIN, GATE_BIN, WICK_GATE_SPEC, GATE_SPEC were all dropped.
Building & shipping
wick build compiles cmd/gate/ as part of its pipeline (no separate CI step). Output goes two places:
internal/agents/gate/assets/gate-<os>-<arch>[.exe]— picked up by//go:embed, shipped inside the main binary.bin/<app>-gate-<os>-<arch>[.exe]— sibling artifact for distribution.
wick build --installer packages the sidecar into the platform-native installer:
| OS | Where the sidecar lands |
|---|---|
| Windows MSI | Same folder as <App>.exe (%LocalAppData%\Programs\<AppName>\<App>-gate.exe) |
| Linux .deb | /usr/bin/<app>-gate |
| macOS .app bundle | Contents/MacOS/<App>-gate |
If your fork strips cmd/gate/, the builder soft-skips — the gate won't be available and the Providers page shows a "gate disabled" banner.
Whitelist rules (spec.json)
The shared spec.json carries two things:
{
"rules": [
{ "tool": "Bash", "match": "git status*" },
{ "tool": "Bash", "match": "ls *" }
],
"auto_approved": [
{ "tool": "Bash", "cmd": "git pull origin main" }
]
}rules— glob patterns evaluated by the gate without a socket round-trip. Edit from/admin/advancedunder theagentsgroup; the daemon rewritesspec.jsonon save and on every Build invocation.auto_approved— exact matches added by Always allow in the modal. Same hot-path: gate reads, matches, emits allow — no daemon round-trip.
Diagnostics
wick doctor
wick doctor wick-lab.exe # inspect a branded buildThe gate section of the doctor report:
| Check | What it verifies |
|---|---|
gate app_name | <app> derived from the binary filename |
gate binary | <app>-gate[.exe] resolves via sibling / embed / PATH |
gate name match | Gate binary stem matches <app> (socket paths align) |
gate socket | Socket path the daemon would use |
gate round-trip | Dial socket + send probe — Probe: true skips the pending queue and daemon auto-replies, proving full encode → decode path |
gate spec | spec.json exists and is non-empty |
A failing round-trip is the most useful signal: binary resolves but daemon isn't listening, or AppName mismatch between gate binary and running daemon.
Test gate button
The Providers page has a per-card Test gate button. It spawns the provider with a force-deny hook in a temp project folder, asks it to touch a sentinel file, and reports whether the file was created. Green = deny envelope honored; red = gate effectively bypassed.
Use this as a smoke test after upgrading a provider CLI — the contract has changed before without warning.
Failure modes
| Situation | Behavior | What you'll see |
|---|---|---|
| Daemon not running | connect() → "no such file" → fail-open allow | Command runs unconditionally |
| Daemon hangs (25s) | Deadline fires → block reason=timeout → deny envelope | Modal disappears mid-render; commands.jsonl entry has decision=block reason=timeout |
spec.json missing | LoadSpec returns empty Spec → falls through to socket dial | Every command hits the modal |
| Stdin missing / malformed | 3s read timeout → deny envelope | Look at the daily tail log |
| Adapter parse error | fail-closed deny + log | Provider sent unexpected payload shape |
| Provider capability probe timeout | HookError=timeout → toggle locked off | Retry via Test button on Providers page |
| Bypass flag + gate ON | Spawner strips hook config, runs unguarded | Gate won't fire — bypass intent (non-interactive channels) takes precedence |
Infrastructure failures outside wick's control fail open; failures inside the gate (malformed stdin, post-dial hang) fail closed.
AskUser policy
ask_user is resolved per session origin, not by the command gate master switch.
| Session origin | Default behavior | How to change |
|---|---|---|
| Web UI / stdio / external MCP | Enabled — routes to the web UI card | Toggle AskUserMode in Configs → agents group |
| Slack | Disabled — returns an error so the agent picks a default | Add ask_user_enabled = true to the Slack channel config |
| Telegram | Disabled | Add ask_user_enabled = true to the Telegram channel config |
| REST | Disabled | Add ask_user_enabled = true to the REST channel config |
Enabling ask_user_enabled on a channel only makes sense once that channel can render the interactive modal; the flag exists to let operators opt in when that UI is available for their deployment.
The global AskUserMode setting is the fallback for web/stdio/MCP sessions. Per-channel flags take full priority over it.
Two patterns of approval
Wick uses system-intercept approval (the gate enforces the prompt). The other pattern, voluntary ask (the AI asks via a tool call), can't be enforced — the agent can forget to call it. The harness-level AskUserQuestion tool also isn't available when the provider runs in pipe mode.
Wick ships a separate AskUser MCP tool for the case where an agent legitimately wants to ask you a question mid-turn. It bridges to a web-UI card via SSE but is voluntary and orthogonal to the gate.
Adding a new provider
See command-gate-multi-provider.md for the full contributor checklist. Short version:
provider/provider.go— addTypeFoo Type = "foo"gate/adapter/foo/— implementadapter.Adapter(Parse + Emit), register viainit()provider/foo/hookconfig.go—WriteHookConfig+RemoveHookConfigprovider/foo/spawn.go— implementprovider.Spawner, skip bypass flag when gate activeprovider/foo/prober.go— implementcapability.Prober, register viainit()provider/foo/capability_init.go—capability.Register("foo", ...)ininit()cmd/gate/main.go— add blank import for the adapterpool/factory.go— addcase "foo"to spawner dispatch switch
See also
- AI Agents — sessions, projects, providers.
wick build—--installerflag bundles the gate sidecar.- Environment Variables —
APP_NAMEnamespacing for~/.<app>/.