Security by Design: The Policy Engine, Sandboxing, and Safety Checkers
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
argsPatternregexes - 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
mcpNamefield 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:
- Extracts the
commandargument from the tool call - Splits compound commands (e.g.,
cmd1 && cmd2) usingsplitCommands() - Parses each sub-command with
shellParse()from theshell-quotelibrary - Matches each sub-command against rules with
commandPrefixpatterns
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
commandPrefixrather thanargsPatternfor command matching. The engine handles pipe chains,&&, and||operators correctly withcommandPrefix, 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'sapprovedToolslist.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 withsandbox-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
disableAlwaysAllowflag on PolicyEngine prevents "always allow" rules from taking effect — useful for environments where administrators want to maintain control. CheckPolicyEngineConfigfor 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.