How to read this page
| Marker | Meaning |
|---|---|
| ✗ KNOWN GAP | Confirmed divergence between behavior and what a reasonable engineer might expect. Filed as an issue. |
| ✗ STRUCTURAL GAP | Architectural absence (e.g. a hook layer that doesn’t exist for some path). Filed. |
| ✗ BY DESIGN | Intentional trade-off; documented so users understand the boundary. |
| ✗ UNCERTAIN | Audit didn’t reach far enough to verify. Investigation issue filed. |
| ✓ CLOSED | Concern that was investigated and resolved — kept here for transparency. |
| Severity | Means |
|---|---|
| High | Active risk to user data, credentials, or system integrity. |
| Medium-High | Reasonable misuse path under normal operation; protections exist but are incomplete. |
| Medium | User expectations diverge from behavior; misuse requires unusual usage. |
| Low-Medium | Edge cases, doc drift, or developer-only UX papercuts. |
| Low | Cosmetic / advisory / unlikely to ever trip. |
GAP-B1 — Pi sessions ignore mcp_tool_permissions user configuration
Severity: Medium · Status: ✗ KNOWN GAP · Page: MCP & Permissions
What goes wrong
What goes wrong
mcp__orion-tools__delegate_to_cli. Sends a chat using a Pi model (Gemini). The agent invokes the tool. It executes without being blocked.Root cause
Root cause
pi-permission-middleware.mjs::classifyTool() at L58-78 treats all mcp__* tools as {destructive:false, isWrite:false, dangerLevel:'low'} and does NOT consult the mcp_tool_permissions DB table.The lookupToolPermission() function in canUseTool.mjs:81-100 IS the right check — but it’s only called from the Claude SDK canUseTool closure at L238-257. The Pi middleware doesn’t delegate to that closure for MCP tools.Fix path
Fix path
- Add
lookupToolPermission(parsed.server, parsed.tool)call insideclassifyTool()formcp__-prefixed tools — return{destructive:true}when permission === ‘block’. - Make
wrapWithPermission()delegate to theboundCanUseToolclosure formcp__tools so the fullcreateCanUseTooldecision tree runs (requires threadingcanUseToolinto middleware opts).
GAP-B2 / GAP-D1 — CCW external CLI spawns have no OS sandbox
Severity: Medium-High · Status: ✗ KNOWN GAP · Pages: MCP & Permissions, BackgroundWhat goes wrong
What goes wrong
mcp__orion-tools__execute_cli_inprocess with tool:'codex', mode:'write', dir:'/Users/sid'. Codex CLI spawns with cwd=/Users/sid, full write access to user’s home directory. No sandbox-exec, no bwrap, no path policy.Root cause
Root cause
ccw/cli/executor.mjs:execute() calls spawn(commandToSpawn, argsToSpawn, { cwd: workingDir, env: spawnEnv }) with no sandbox wrapper. The Pi sandbox’s @carderne/sandbox-runtime only applies to Pi’s own Bash tool — not to spawned subprocesses.What IS protected
What IS protected
buildSpawnEnv()strips 28 Orion-internal env vars (credentials, OAuth, DB paths) — executor.mjs:128-175- 10-minute timeout
- Caller-supplied
cwdboundary
What's NOT protected
What's NOT protected
- Filesystem write scope (spawned CLI can write to any path accessible to sidecar)
- Network scope (CLI uses its own auth tokens for arbitrary calls)
mode: 'analysis'vs'write'enforcement (Orion passes as flag, CLI may ignore)
Fix path
Fix path
spawn() call with wrapCommand() from @carderne/sandbox-runtime (same primitive Pi’s Bash sandbox uses). Mode-aware policy:mode: 'analysis'→ all writes deniedmode: 'write'→ vault root + caller-supplied dir only
GAP-B3 — MCP-bridged file tools bypass denyWrite floor
Severity: Medium · Status: ✗ STRUCTURAL GAP · Page: MCP & Permissions
What goes wrong
What goes wrong
mcp__orion-tools__write_file with path targeting .env (which is in the Pi sandbox denyWrite floor). The path-traversal check in the handler passes (.env isn’t a traversal attempt). The denyWrite floor is never consulted because MCP-bridged tools bypass the Pi sandbox fs-wrappers.Root cause
Root cause
mcp-bridge.mjs:134-146, bypassing pi-sandbox/fs-wrappers.mjs::assertSandboxPath(). Only validatePath() from shell-utils.mjs runs — and that checks control characters and path traversal but NOT the denyWrite pattern list.Risk assessment
Risk assessment
mcp__orion-tools__* tool handlers are Orion-controlled trusted code — they don’t currently abuse this. But the absence of the denyWrite enforcement layer means a future tool addition (or a third-party MCP server registered into the same namespace) could inadvertently allow credential file writes without triggering the hard-block.Fix path
Fix path
- Wrap
mcp__orion-tools__edit_file/write_filehandlers with explicitassertSandboxPath()calls. - Extend
bridgeMcpTools()inmcp-bridge.mjsto inject sandbox checks for any tool with file-path inputs (detect via Zod schema introspection).
GAP-D2 — Plain cron jobs bypass AI-WITH-YOU gate
Severity: Medium · Status: ✗ KNOWN GAP (by design but should be documented) · Page: BackgroundWhat goes wrong
What goes wrong
payload.message: "Send my weekly digest to Slack" and schedule 0 9 * * MON. Job fires Monday 9am, LLM executes message, Slack MCP tool sends digest. User has no acceptance step.Root cause
Root cause
payload.type === 'autopilot' cron jobs — they get short-circuited into autopilot/runner.mjs at executor.mjs:260.Plain payload.message cron jobs (the most common kind users create directly) skip this branch and run end-to-end on cadence.Mitigating factors
Mitigating factors
- Tools called by cron jobs still go through
canUseTool(Claude path) or pi-permission-middleware (Pi path). - In
bypassPermissionscron mode, the middleware skips wrapping — meaning tools run without prompts. - Cron jobs are visible in the UI; user can see what fired and disable.
Fix path
Fix path
- Introduce
cron_jobs.acceptance_modecolumn mirroring autopilot’s pattern, defaulting to'suggest'for user-created cron jobs (auto for system jobs like heartbeat). - Document this prominently as expected behavior (cron = scheduled execution, not gated approval) — and ensure the cron creation UI surfaces this clearly.
GAP-A1 — Frozen system prompt on reused Pi sessions
Severity: Low-Medium · Status: ✗ KNOWN GAP (perf vs UX trade-off) · Page: SessionsWhat goes wrong
What goes wrong
config.yaml mid-conversation, or installs a new skill. They expect the change to take effect on the next message — but it doesn’t, until the session is restarted.Root cause
Root cause
AgentSession cache hit at handler.mjs:2191-2234 reuses the existing session — skipping the first-turn setup at pi-session.mjs:294-591. System prompt, MCP tool list, and skill discovery results are baked in at session creation time.Trade-off
Trade-off
Documented at
Documented at
GAP-C1 — ProvenanceCaption gate is advisory, not enforced
Severity: Low · Status: ✗ KNOWN GAP · Page: FrontendWhat goes wrong
What goes wrong
SlideDeck, MetricCard, or Chart widget spec without a ProvenanceCaption child. The widget renders without error. The user sees AI-generated content with no source citation.Root cause
Root cause
widget-catalog.ts:173-186) describes the gate in the component’s description string, which feeds the LLM system prompt via catalog.prompt(). There is no runtime validator that rejects widget specs lacking provenance.Enforcement is convention-based — and convention breaks under model variance, Pi tool calls (which may not see the same prompt), and YAML specs from external sources.Fix path
Fix path
validateProvenance() step to the 7-step widget fix pipeline in useCanvasToolInterceptor.ts. Reject (or warn-and-flag) widgets in presentationComponents and dataComponents groups that lack ProvenanceCaption in their tree.GAP-D4 — Claude CLI deliberately inherits user’s ~/.claude/
Severity: Low-Medium · Status: ✗ BY DESIGN · Page: Background
What this means
What this means
ccw/cli/executor.mjs:645-649 explicitly strips CLAUDE_CONFIG_DIR from the spawned Claude CLI’s env. The CLI falls back to ~/.claude/ (user’s own Claude Code config), not Orion’s runtime config.- User’s personal skills, hooks, settings.json, MCP servers → loaded into spawned Claude CLI.
- Spawned CLI uses user’s OAuth tokens, not Orion’s Cloudflare-Worker-proxied tokens.
- Billing flows through user’s Anthropic account.
Why it's by design
Why it's by design
CLAUDE_CONFIG_DIR=prompts/ keeps user config out of Orion’s runtime, but when Orion delegates to Claude CLI, the user expects the CLI to behave like their Claude CLI. Cross-billing wouldn’t make sense.Why it's worth documenting
Why it's worth documenting
GAP-A8 — SubagentJsonlWriter rotation policy unverified
Severity: Low · Status: ✗ UNCERTAIN (investigation issue filed) · Page: SubagentsWhat might go wrong
What might go wrong
<vault>/.orion/subagent-events/<rootConversationId>/events.jsonl could grow unbounded if rotation isn’t wired. Long-running root conversations with heavy subagent use would accumulate large logs.Status
Status
SubagentJsonlWriter exists and is wired into Pi subagent dispatch, but didn’t verify rotation policy (size-based, time-based, or absent). Investigation issue filed.Fix path if absent
Fix path if absent
.1, .2 suffixes) to avoid unbounded growth.GAP-A7 — Top-level Pi timeout for interactive sessions
Severity: Low-Medium · Status: ✗ UNCERTAIN (investigation issue filed) · Page: SessionsWhat might go wrong
What might go wrong
PI_TIMEOUT_BY_EFFORT (cron/executor.mjs:56-61). It’s unclear whether interactive Pi sessions have an equivalent top-level timeout. If absent, an LLM hang could keep the sidecar busy indefinitely.Status
Status
Fix path if absent
Fix path if absent
PI_TIMEOUT_BY_EFFORT parameterized for interactive (longer ceiling than cron, e.g., 10 min).GAP-D3 — Boot-time orphan reconciliation for cron_runs.status='running'
Severity: Unknown (potentially Medium) · Status: ✗ UNCERTAIN (investigation issue filed) · Page: Background
What might go wrong
What might go wrong
cron_runs rows with status='running' are left in the DB. On next sidecar start, those rows should be reconciled (e.g., marked failed or interrupted). If reconciliation is absent, the UI shows ghost-running jobs.Status
Status
cron-service.mjs lines 200-1800. Investigation issue filed.Fix path if absent
Fix path if absent
UPDATE cron_runs SET status='interrupted', error='sidecar restart' WHERE status='running' AND started_at < ?.Other gaps (compact)
GAP-C2 — Small-widget threshold (≤10 elements) creates silent non-display
GAP-C2 — Small-widget threshold (≤10 elements) creates silent non-display
GAP-C3 — Spreadsheet mutations have no auto-save
GAP-C3 — Spreadsheet mutations have no auto-save
sheet_operation mutations set isDirty:true but don’t trigger auto-save (unlike TipTap’s 2s debounce). User must save manually. App close while dirty → changes lost. Severity: Low-Medium.GAP-C5 — Tool call dedup uses possibly-null toolItem.id
GAP-C5 — Tool call dedup uses possibly-null toolItem.id
processedRef.has(toolItem.id) — if Pi’s toolComplete emits null id, canvas tools from Pi sessions could silently fail. ? INFERRED, needs verification. Severity: Low.GAP-C6 — YAML spec parse failures silently produce blank canvas
GAP-C6 — YAML spec parse failures silently produce blank canvas
createYamlStreamCompiler().flush() throws and no JSON fallback exists, the warning is logged but the user sees a blank canvas with no error message. Severity: Low.GAP-C7 — canvasStore.openFile uses extension-only, not filename map
GAP-C7 — canvasStore.openFile uses extension-only, not filename map
canvasStore.openFile get extension='' and fall to fallback mode instead of correct mode. Inconsistent with file-router.ts::getCanvasModeForFile(). Severity: Low.GAP-A2 — EMPTY_QUERY_RESULT sentinel is Pi-only
GAP-A2 — EMPTY_QUERY_RESULT sentinel is Pi-only
GAP-A3 — parentToolUseId propagation asymmetry
GAP-A3 — parentToolUseId propagation asymmetry
pi-subagent-runner.mjs. Equivalent in practice but Pi codepath could regress. Defensible via regression tests. Severity: Low.GAP-A4 — sessionId vs conversationId naming
GAP-A4 — sessionId vs conversationId naming
GAP-A5 / A6 — OR_PREFIX drift, JSDoc-vs-code timeout drift
GAP-A5 / A6 — OR_PREFIX drift, JSDoc-vs-code timeout drift
Closed gaps (what IS protected)
✓ Pi sandbox denyWrite floor covers critical credential paths
✓ Pi sandbox denyWrite floor covers critical credential paths
policy-loader.mjs:82-127 — .env, .env.*, *.pem, *.key, **/.ssh/**, **/.aws/**, **/.gnupg/**, **/orion.db, **/com.orion.butler/**, **/prompts/projects/**, **/.orion/mcp-oauth/**. Users CANNOT remove these built-ins. ✓ VERIFIED.✓ MCP OAuth tokens survive shutdown()
✓ MCP OAuth tokens survive shutdown()
{vault}/.orion/mcp-oauth/<server>/tokens.json survive oauthSingleton.shutdown() — users don’t re-authorize on every Orion launch. Cleared only on vault switch via reset({newVaultRoot}). ✓ VERIFIED.✓ Credential isolation when spawning external CLIs
✓ Credential isolation when spawning external CLIs
buildSpawnEnv() strips 28 keys including ANTHROPIC_, LANGFUSE_, ORION_*, FIREBASE_UID, GEMINI_API_KEY, ORION_DB_PATH. Spawned CLIs cannot inherit Orion credentials. ✓ VERIFIED.✓ Internal MCP servers auto-trusted on Claude path
✓ Internal MCP servers auto-trusted on Claude path
BAKED_IN_TRUSTED_SERVERS + internalServers Set + canUseTool L222-234. Orion’s own MCP tools don’t prompt the user — by design. ✓ VERIFIED.✓ Session table dual-write invariant enforced atomically
✓ Session table dual-write invariant enforced atomically
session_index and conversations inside a single rusqlite transaction with RAII rollback on filesystem-copy failure. ✓ VERIFIED in session_lineage.rs.Filed issues
All seven gaps filed as GitHub issues on sidart10/orion-butler on 2026-05-17, under labelssafety-gap, architecture-audit, from-explainer-v3.
| # | Issue | Gap | Severity |
|---|---|---|---|
| #77 | Pi sessions ignore mcp_tool_permissions user configuration | GAP-B1 | Medium |
| #78 | CCW external CLI spawns have no OS sandbox | GAP-B2 / GAP-D1 | Medium-High |
| #79 | MCP-bridged file tools bypass Pi sandbox denyWrite floor | GAP-B3 | Medium |
| #80 | Plain cron jobs bypass the AI-WITH-YOU acceptance gate | GAP-D2 | Medium |
| #81 | Investigate: boot-time orphan reconciliation for cron_runs | GAP-D3 | Unknown |
| #82 | Investigate: top-level Pi timeout for interactive sessions | GAP-A7 | Low-Medium |
| #83 | Investigate: SubagentJsonlWriter rotation policy | GAP-A8 | Low |