Skip to content

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 it

Provider 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

ProviderHook nameAllow outputDeny outputExit (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
GeminiBeforeToolexit 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:

FieldWhat it controlsValues
PermissionModePer-tool permission prompts (the PreToolUse hook on each provider)on (install hook) / bypass (skip — for Slack / HTTP / cron channels where no human can answer)
AskUserModeThe 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

StateBadge
PermissionMode: bypasslocked (bypass)
Master offlocked
Master on + probe in flighttesting…
Master on + intent on + verifiedenabled ✓
Master on + intent on + unverifiedenabled (unverified)
Master on + intent off + probe passedready
Master on + intent offdisabled

Intercept scope per provider

ProviderScope
claudeAll tools — Bash, Read, Write, Edit, Glob, MCP tools, and any future tools
codexShell commands only
geminiUntested — 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 typeGate behavior
BashWhitelist check → auto-approved check → ask user
Read / Write / Edit / GlobScope check — if path is within the project default_scope, auto-allow; otherwise ask user
wick read-only / info MCP toolsAlways 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 toolsAlways 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:

ToolPurpose
wick_listList connectors / accounts
wick_searchSearch connectors
wick_getRead a connector's schema
wick_infoServer info
wick_list_providersList agent providers
wick_skill_listList available skills
wick_session_infoRead session metadata
wick_set_titleUpdate the session sidebar title
wick_session_workspaceManage per-session ephemeral connector instances
ask_user / AskUserQuestionRoute 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:

ButtonAPI valueScopeEffect on future matching calls
Approve onceapprove_onceThis request onlyModal again next time
Allow this sessionapprove_sessionWhile the session lives (in-memory)Auto-approved silently for this session
Always allowapprove_alwaysPersistent (written to spec.json)Auto-approved across restarts, all sessions
BlockblockThis request onlyTool 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 (shellBash, apply_patchEdit) 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 0

The 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:

go
// 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.log

Why 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:

jsonl
{"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.exewick-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:

  1. sibling-of-executable<app>-gate[.exe] next to the main binary. Primary path when installed via wick build --installer (MSI / .deb / .app ships the sidecar alongside the main binary).
  2. embedded extract//go:embed assets/gate-<os>-<arch> unpacked once into a temp location. Backup for portable .exe / source builds.
  3. PATHexec.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:

OSWhere the sidecar lands
Windows MSISame folder as <App>.exe (%LocalAppData%\Programs\<AppName>\<App>-gate.exe)
Linux .deb/usr/bin/<app>-gate
macOS .app bundleContents/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:

json
{
  "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/advanced under the agents group; the daemon rewrites spec.json on 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

bash
wick doctor
wick doctor wick-lab.exe    # inspect a branded build

The gate section of the doctor report:

CheckWhat it verifies
gate app_name<app> derived from the binary filename
gate binary<app>-gate[.exe] resolves via sibling / embed / PATH
gate name matchGate binary stem matches <app> (socket paths align)
gate socketSocket path the daemon would use
gate round-tripDial socket + send probe — Probe: true skips the pending queue and daemon auto-replies, proving full encode → decode path
gate specspec.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

SituationBehaviorWhat you'll see
Daemon not runningconnect() → "no such file" → fail-open allowCommand runs unconditionally
Daemon hangs (25s)Deadline fires → block reason=timeout → deny envelopeModal disappears mid-render; commands.jsonl entry has decision=block reason=timeout
spec.json missingLoadSpec returns empty Spec → falls through to socket dialEvery command hits the modal
Stdin missing / malformed3s read timeout → deny envelopeLook at the daily tail log
Adapter parse errorfail-closed deny + logProvider sent unexpected payload shape
Provider capability probe timeoutHookError=timeout → toggle locked offRetry via Test button on Providers page
Bypass flag + gate ONSpawner strips hook config, runs unguardedGate 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 originDefault behaviorHow to change
Web UI / stdio / external MCPEnabled — routes to the web UI cardToggle AskUserMode in Configs → agents group
SlackDisabled — returns an error so the agent picks a defaultAdd ask_user_enabled = true to the Slack channel config
TelegramDisabledAdd ask_user_enabled = true to the Telegram channel config
RESTDisabledAdd 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:

  1. provider/provider.go — add TypeFoo Type = "foo"
  2. gate/adapter/foo/ — implement adapter.Adapter (Parse + Emit), register via init()
  3. provider/foo/hookconfig.goWriteHookConfig + RemoveHookConfig
  4. provider/foo/spawn.go — implement provider.Spawner, skip bypass flag when gate active
  5. provider/foo/prober.go — implement capability.Prober, register via init()
  6. provider/foo/capability_init.gocapability.Register("foo", ...) in init()
  7. cmd/gate/main.go — add blank import for the adapter
  8. pool/factory.go — add case "foo" to spawner dispatch switch

See also

Built with ❤️ by a developer, for developers.