-
-
Notifications
You must be signed in to change notification settings - Fork 0
Security Model
M31A executes shell commands on the user's behalf. Security is enforced at multiple layers: the permission system, input validation, sandboxing, and the OS keychain.
Every tool declares its risk level:
| Level | Description | Default Action |
|---|---|---|
safe |
Read-only operations (FileRead, Glob, Grep) | Auto-allow |
medium |
Write operations (FileWrite, Edit, WebFetch) | Ask user |
dangerous |
Shell commands (Bash) | Ask user |
destructive |
Destructive operations (FileDelete, rm -rf) |
Always ask |
Configured in [permissions.rules]:
[[permissions.rules]]
tool = "Bash"
pattern = "rm -rf"
risk_level = "destructive"
action = "deny"
[[permissions.rules]]
tool = "Bash"
pattern = "go test"
risk_level = "safe"
action = "allow"
[[permissions.rules]]
tool = "Bash"
pattern = "git *"
action = "ask"Different agent profiles can have different permission defaults:
[permissions.agents.build]
default_action = "allow"
[[permissions.agents.build.rules]]
tool = "Bash"
pattern = "go build"
action = "allow"Tool call arrives
│
▼
Rule matching (tool name + pattern)
├── Match: allow/deny/ask
│
▼
Agent default check
├── Has default: use it
│
▼
Risk level assessment
├── safe/medium: allow
├── dangerous: ask user (interactive) or block (non-interactive)
└── destructive: always ask user
The TUI shows a modal for ask decisions:
| Key | Action |
|---|---|
y |
Allow this execution |
a |
Allow always (remember for session) |
n |
Deny |
e |
Exit application |
The modal has a configurable timeout (permissions.timeout_seconds, default 300s).
Request/response routing uses per-request channels with monotonic IDs:
var permissionRequestID atomic.Int64
type PermissionRequest struct {
ID int64
ToolName string
Command string
RiskLevel types.RiskLevel
TimeoutSecs int
}This prevents cross-caller response mix-ups when multiple permission requests are in flight (important for concurrent subagents).
Source: pkg/keychain/
API keys are stored in the OS-native keychain, never in plaintext on disk:
| Platform | Backend |
|---|---|
| Linux | D-Bus Secret Service API + pass CLI fallback |
| macOS | Keychain Access (/usr/bin/security) |
| Windows | Credential Manager |
Keys are identified by m31a/<service_name> with account m31a.
If API keys are in config.toml, they are read at startup and resolved through the keychain when possible. Environment variables (OPENROUTER_API_KEY, ZEN_API_KEY) are also supported as alternatives.
| Limit | Value | Purpose |
|---|---|---|
MaxFileSize |
5 MB | Maximum file read size |
MaxSessionFileSize |
50 MB | Maximum session file size (OOM protection) |
MaxLLMResponseBytes |
1 MB | Maximum LLM response size |
BashOutputLimit |
50,000 chars | Maximum bash output |
MaxToolOutputChars |
10,000 chars | Maximum tool output |
| Limit | Value | Purpose |
|---|---|---|
| Max parameters | 1,000 per tool call | Prevent abuse |
| Max tool calls | 16 per LLM response | Prevent runaway execution |
| Max glob results | 1,000 (configurable) | Prevent memory issues |
| Max grep results | 100 (configurable) | Prevent memory issues |
- Process groups: Commands run in their own process group for clean signal forwarding
- Grace period: SIGINT first, SIGKILL after 5-second grace period
- Stdin closed: Child processes get EOF immediately (prevents interactive hangs)
-
Non-interactive env:
CI=true,DEBIAN_FRONTEND=noninteractive,PIP_NO_INPUT=1 - Binary detection: Null byte check in first 512 bytes
-
Output capping:
limitWritersilently drops bytes beyond 50K limit
Source: internal/tools/webfetch.go
WebFetch blocks access to:
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Loopback addresses (127.0.0.0/8, ::1)
- Link-local addresses (169.254.0.0/16, fe80::/10)
Returns ErrPrivateIPBlocked when a private IP is detected.
The tool dispatcher uses a token bucket rate limiter:
-
Burst capacity:
ToolRateLimitBursttokens -
Refill rate:
ToolRateLimitPerSectokens per second - Behavior: Blocks tool execution when tokens are exhausted
This prevents resource exhaustion from LLMs generating thousands of tool calls per second.
| Operation | Timeout | Configurable |
|---|---|---|
| Bash command | 30 minutes | Per-call (max 1800s) |
| Permission modal | 300 seconds | permissions.timeout_seconds |
| Health check | 10 seconds | features.health_check_timeout_secs |
| Model fetch | 15 seconds | Fixed |
| Verify task | 5 minutes | Fixed |
| HTTP dial | 30 seconds | Fixed |
Source: internal/fileutil/atomic.go
All file writes use atomic semantics:
- Write to temp file
-
fsync()to flush - Rename temp to target
- Prevents corruption on crash
SIGTERM and SIGINT are handled through Bubble Tea's Send() channel, ensuring all state mutations happen inside the single-threaded Update() loop. A 5-second hard fallback timer (os.Exit(1)) prevents the TUI from hanging on shutdown, with a sentinel file written for unclean shutdown detection.
When features.budget_limit_usd is set, the workflow engine checks cumulative cost before each phase using atomic operations on a uint64 (stored as float64 bits):
cost := math.Float64frombits(atomic.LoadUint64(&e.totalCostBits))
if cost >= cfg.Features.BudgetLimitUSD {
return error("budget limit exceeded")
}Zero telemetry. M31A does not phone home, collect usage data, or send analytics. The only network requests are to the configured LLM provider API.