Safety System

Gates

A gate is a pause button. When a worker tries to do something that looks risky, Nyx stops and waits for you to say "yes" or "no" before continuing. Here's exactly how that works and what goes wrong when nobody decides.

🚦

The one-sentence version

A gate is a traffic light that turns red when a worker tries a command that could touch something sensitive, like a secrets file or credentials. The worker stops at the light and waits. You decide: green (approve) or red (reject). If nobody shows up to flip the light, it auto-turns red after 10 minutes and the worker has to try a different route.

What triggers a gate?

The moderator watches every shell command a worker tries to run. If the command matches a pattern that looks like it might touch sensitive files, the moderator intercepts it before it executes and creates a gate.

The current patterns that trigger a gate are:

# These patterns auto-gate any command that matches: \.env(\.|$) ← anything touching a .env file (e.g. cat .env, cat .env.example) credentials? ← any command mentioning "credential" or "credentials" secrets? ← any command mentioning "secret" or "secrets"
Note: the rules are coarse right now

cat .env.example gets gated even though .env.example is meant to be committed and contains no real secrets. The rule doesn't yet distinguish read vs write, or .env.example vs .env. Smarter rules are on the roadmap.

What happens when a gate is created?

1

Worker tries a command

Example: cat api/.env.example to see what env vars the app expects.

2

Moderator intercepts it

The command never runs. Instead, a row is inserted into the push_gate_queue database table with status pending. The worker's HTTP request hangs open, waiting for an answer.

3

Waiting for a decision

The gate sits at pending until someone approves or rejects it via the TUI or API. Right now there's no banner or alarm in the TUI — gates just silently pile up.

4a

Nobody decides → timeout at 10 minutes

After 10 minutes with no answer, the gate auto-rejects. The worker gets a "permission denied" result back from the tool call. Status becomes timed out.

4b

Worker retries → new gate → repeat

The worker sees "permission denied," reasons that it should try a slightly different approach to the same task, and runs another command that matches the same pattern. A new gate is created. Another 10-minute hang begins. Spend climbs because every retry costs an API call.

5

You decide → gate resolves immediately

If you approve: the command runs, the worker gets the result and continues normally.
If you reject: the worker gets a clean "permission denied" and knows not to retry that exact path. Status becomes approved or rejected.

What actually happened

Between 2026-05-28 22:20 and 2026-05-30, 35 gates accumulated with status pending and zero resolved. Nobody knew to look at them because there was no visible signal in the TUI or terminal.

27 of the 35 were workers trying to read .env.example files — completely harmless read-only operations that happened to match the gating regex. Each one timed out after 10 minutes, the worker retried, and the cycle continued. During every 10-minute hang, no other work could proceed in that worker slot.

What was happening

  • 35 gates pending, 0 resolved
  • Workers hanging 10min per retry
  • Each timeout = wasted API call
  • No visible signal anywhere
  • Spend tracking: $873

What will happen after fixes

  • Gates column visible in TUI
  • Banner if gates pile up
  • Smarter regex (.env.example whitelisted)
  • Reject-all / approve-all bulk action
  • Workers route around safely
Why model choice doesn't help

Switching to a more capable model (Opus vs Sonnet) makes no difference here. Every model hits the same floor.ts regex and the same 10-minute waiter. A smarter model might find a non-gated workaround faster, but it can't skip the gate. Opus costs 5x more per call, which makes the retry loop significantly more expensive.

How to approve or reject

Gates live in the push_gate_queue table. Right now, the fastest way to drain them is via the moderator API directly. A TUI gate column is being built so you can do this without leaving the terminal.

Approve a single gate

# Replace GATE_ID with the id from push_gate_queue curl -X POST http://localhost:3000/internal/permission/GATE_ID/approve

Reject a single gate

curl -X POST http://localhost:3000/internal/permission/GATE_ID/reject

See all pending gates

curl http://localhost:3000/internal/permission/pending
Approve-all vs reject-all

Approve-all unblocks every waiting worker immediately. Best when you trust the batch and want to resume work fast.

Reject-all kills the stalled workers cleanly. Best when you want a fresh start — the concierge can then re-dispatch with better instructions that don't hit the gate.

When should you approve vs reject?

Approve when the command is read-only and the file doesn't contain real secrets. cat .env.example, git check-ignore api/.env, ls config/ are all safe to approve.

Reject when the command is trying to write to, copy, or expose a real secrets file. Anything touching .env (not .env.example), private keys, or API credentials in a non-template file.

Gates column in the TUI

The fix for "35 gates with no signal" is adding a dedicated column to the TUI that shows pending gates in real time. When gates pile up, a banner will flash across the top of the screen so it's impossible to miss.

The column will show: which worker triggered the gate, what command it tried to run, and buttons to approve or reject inline without switching to a separate terminal.

Also on the roadmap: smarter floor rules

The regex will be updated to whitelist .env.example files (which contain no real secrets) and to distinguish read commands (cat, head, grep) from write commands (cp, mv, echo >, sed -i). Most of the 35-gate incident would have been avoided with this change alone.

Gate statuses at a glance

pending

Waiting for your decision. Worker is blocked.

approved

You said yes. Command ran. Worker continued.

rejected

You said no. Worker got a clean denial.

timed out

Nobody decided. Auto-rejected after 10 min.

Key numbers

# Gate timeout before auto-reject: 10 minutes # Table name in SQLite DB: push_gate_queue # Moderator endpoint for pending gates: GET /internal/permission/pending POST /internal/permission/:id/approve POST /internal/permission/:id/reject