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 bypass_permissions is set in agents config (non-interactive channels), the master switch shows a locked (bypass) badge and refuses toggles. Turn bypass off first.

Capability badges per provider card

StateBadge
bypass_permissions onlocked (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 workspace default_scope, auto-allow; otherwise ask user
MCP tools (e.g. mcp__support-tools__wick_execute)Always ask user (no scope to check)
Unknown / future toolsAlways ask user

File tools within the workspace scope are auto-allowed without a popup, so normal agent file operations stay fast. Only out-of-scope file access and all MCP/shell calls prompt you.

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 workspace 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 workspace 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/configs 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 workspace, 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.

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.