Read OSS

Security by Design: The Policy Engine, Sandboxing, and Safety Checkers

Advanced

Prerequisites

  • Article 3: Tools and the Scheduler
  • Understanding of security sandboxing concepts
  • Familiarity with TOML configuration format

Security by Design: The Policy Engine, Sandboxing, and Safety Checkers

An agentic coding assistant that can run shell commands and edit files on your machine needs robust security. Gemini CLI addresses this with three layered defenses: a rule-based policy engine that controls what tools can do, platform-specific sandboxing that constrains where they can do it, and pluggable safety checkers that add context-aware judgment. This article examines each layer.

The PolicyEngine: Rule-Based Access Control

The PolicyEngine is the first line of defense. It evaluates tool calls against a sorted list of rules, each producing one of three decisions: ALLOW, DENY, or ASK_USER.

Rules are loaded from TOML policy files and sorted by priority (highest first). When a tool call arrives, the engine finds the first matching rule and returns its decision. The structure looks like:

[[rules]]
toolName = "shell"
decision = "allow"
commandPrefix = "git status"
priority = 100

[[rules]]
toolName = "shell"
decision = "deny"
commandPrefix = "git push --force"
priority = 200

[[rules]]
toolName = "mcp_myserver_*"
decision = "ask_user"
priority = 50

The check() method at line 488 is the entry point. It handles several complexities:

  • MCP server name resolution — extracts server names from tool annotations or parses FQN strings like mcp_serverName_toolName
  • Args pattern matching — uses stable JSON stringification with sorted keys to match argsPattern regexes
  • Tool alias resolution — checks both current and legacy tool names via getToolAliases()
  • Shell command special handling — detects shell tool calls and defers to command-level parsing
flowchart TD
    A[PolicyEngine.check(toolCall)] --> B[Resolve serverName]
    B --> C[Compute stringified args]
    C --> D{Is shell command?}
    D -- Yes --> E[Parse command for<br/>per-command policy]
    D -- No --> F[Match against rules]
    E --> F
    F --> G{First matching rule?}
    G -- Found --> H[Return rule.decision]
    G -- None --> I{Safety checkers match?}
    I -- Yes --> J[Run checker]
    I -- No --> K[Return default decision]
    J --> L[Return checker result]

Rule matching via ruleMatches() at line 79 supports:

  • Approval mode filtering — rules can specify which modes (DEFAULT, YOLO, AUTO_EDIT, PLAN) they apply to
  • Sub-agent scoping — rules can target specific sub-agents
  • MCP server matching — via mcpName field or wildcard patterns
  • Annotation matching — rules can require specific tool annotations
  • Interactive/non-interactive filtering — different rules for piped vs. terminal usage

Shell Command Parsing for Arg-Level Policy

Shell commands receive special treatment because git status and git push --force are the same tool (shell) with very different risk profiles. The policy engine parses shell commands to evaluate policies at the argument level.

When a shell tool call is detected, the engine:

  1. Extracts the command argument from the tool call
  2. Splits compound commands (e.g., cmd1 && cmd2) using splitCommands()
  3. Parses each sub-command with shellParse() from the shell-quote library
  4. Matches each sub-command against rules with commandPrefix patterns

This enables precise policies like "allow npm test but block npm publish" without blanket-allowing or -denying all shell commands. The commandPrefix field in rules supports both single strings and arrays for multi-prefix matching.

flowchart TD
    A["shell tool call:<br/>npm test && npm publish"] --> B[splitCommands]
    B --> C["Sub-command 1: npm test"]
    B --> D["Sub-command 2: npm publish"]
    C --> E{Match rules}
    D --> F{Match rules}
    E --> G["Rule: allow npm test → ALLOW"]
    F --> H["Rule: deny npm publish → DENY"]
    G --> I{Both sub-commands}
    H --> I
    I --> J["Final: DENY<br/>(most restrictive wins)"]

Tip: When writing TOML policy rules for shell commands, use commandPrefix rather than argsPattern for command matching. The engine handles pipe chains, &&, and || operators correctly with commandPrefix, but raw regex on stringified args would break on complex shell syntax.

Platform-Specific Sandboxing

The sandbox layer operates at the OS level, constraining what the entire process can access. The SandboxManager interface defines the contract, with platform-specific implementations.

The MacOsSandboxManager uses macOS seatbelt profiles (the sandbox-exec system) to restrict filesystem access, network, and system calls. Its key methods:

  • isKnownSafeCommand(args) — Fast-path for commands known to be harmless (e.g., ls, cat). Also checks the mode config's approvedTools list.
  • isDangerousCommand(args) — Identifies obviously dangerous operations.
  • prepareCommand(req) — The core method. It sanitizes environment variables, builds a seatbelt profile based on permissions (readonly mode, workspace write access, network access), and wraps the command with sandbox-exec.
graph TD
    subgraph "Platform Sandbox Managers"
        MAC[MacOsSandboxManager<br/>seatbelt profiles]
        DOCKER[Docker/Podman<br/>container isolation]
        WIN[WindowsSandboxManager<br/>Windows sandbox]
        NOOP[NoopSandboxManager<br/>no restrictions]
    end
    
    SI[SandboxManager Interface]
    SI --> MAC
    SI --> DOCKER
    SI --> WIN
    SI --> NOOP
    
    POLICY[SandboxPolicyManager<br/>persistent permissions]
    MAC --> POLICY
    DOCKER --> POLICY

The macOS implementation also translates virtual commands: __read becomes /bin/cat and __write becomes a cat > "$1" pipeline, allowing sandboxed file access through controlled primitives.

On Linux, Docker or Podman containers provide isolation. The sandbox decision happens early in the boot sequence (as we saw in Article 1): if sandboxing is enabled and we're not already in a sandbox, the process relaunches itself inside the container.

Safety Checkers: CheckerRunner and Conseca

Beyond rule-based policy, Gemini CLI supports pluggable safety checkers that can apply more nuanced judgment to tool calls. The CheckerRunner coordinates two types:

In-process checkers — Run directly in the Node.js process. The CheckerRegistry resolves the checker by name, the ContextBuilder assembles the required context, and the checker returns ALLOW, DENY, or ASK_USER.

External checkers — Run as separate processes with a timeout (default 5 seconds). The tool call details are passed via stdin as JSON, and the checker's stdout is parsed against a Zod schema for the result.

const SafetyCheckResultSchema = z.discriminatedUnion('decision', [
  z.object({ decision: z.literal(SafetyCheckDecision.ALLOW), reason: z.string().optional() }),
  z.object({ decision: z.literal(SafetyCheckDecision.DENY), reason: z.string().min(1) }),
  z.object({ decision: z.literal(SafetyCheckDecision.ASK_USER), reason: z.string().min(1) }),
]);

Safety checker rules are defined alongside policy rules in the TOML configuration. They have their own priority sorting and matching logic (including tool name wildcards and args patterns), but produce SafetyCheckDecision values that feed back into the policy pipeline.

The ConsecaSafetyChecker is an in-process implementation that uses heuristics and context about the conversation to evaluate tool calls. Its enable flag (enableConseca) flows from the Config.

Approval Modes and the Confirmation Flow

Gemini CLI supports four approval modes that change how aggressively tools are auto-approved:

Mode Behavior
DEFAULT All destructive tools require confirmation
YOLO Auto-approve everything (use in trusted environments)
AUTO_EDIT Auto-approve file edits, confirm shell commands
PLAN Read-only mode, block all writes and execution

These modes interact with policy rules through the modes field — a rule can specify it only applies in certain modes. For example, a YOLO-mode rule might have priority: PRIORITY_YOLO_ALLOW_ALL to override everything.

The confirmation flow integrates all layers:

stateDiagram-v2
    [*] --> PolicyCheck: Tool call arrives
    PolicyCheck --> AutoAllow: ALLOW
    PolicyCheck --> AutoDeny: DENY
    PolicyCheck --> CheckerPhase: ASK_USER
    
    CheckerPhase --> AutoAllow: Checker says ALLOW
    CheckerPhase --> AutoDeny: Checker says DENY
    CheckerPhase --> HookPhase: Checker says ASK_USER
    
    HookPhase --> AutoAllow: Hook says proceed
    HookPhase --> AutoDeny: Hook says block
    HookPhase --> UIConfirmation: Hook says ask user
    
    UIConfirmation --> Executed: User approves
    UIConfirmation --> Cancelled: User denies
    UIConfirmation --> PolicyUpdate: User says "always allow"
    
    PolicyUpdate --> Executed: Update rules, then execute
    
    AutoAllow --> Executed
    AutoDeny --> Cancelled

When a user selects "always allow" for a tool, updatePolicy() in the scheduler creates a new rule with appropriate specificity. For shell commands, it captures the commandPrefix; for MCP tools, it captures the mcpName. The user can optionally persist this rule to disk with "always allow and save."

Tip: The disableAlwaysAllow flag on PolicyEngine prevents "always allow" rules from taking effect — useful for environments where administrators want to maintain control. Check PolicyEngineConfig for this and other security knobs.

The security architecture's strength is in its layering. Policy rules provide coarse-grained control, sandboxing constrains the execution environment, and safety checkers add context-aware judgment. Each layer can independently block a tool call, and the confirmation flow ensures the user remains in control when automated decisions aren't sufficient.

In the next article, we'll explore the extensibility surfaces built on top of this security foundation — hooks, skills, MCP integration, and the extension packaging system.