-
Notifications
You must be signed in to change notification settings - Fork 2
Inject host agent settings into guest VM #53
Description
Context
brood-box currently injects credentials, git config, and MCP config into the guest VM, but not the user's global agent settings — rules, skills, agents/personas, commands, instructions, and configuration files. Without these, the coding agent inside the VM starts with a "blank slate," missing the user's customizations that make it productive.
The goal is a declarative, per-agent settings injection framework that copies host settings into the guest rootfs before boot. It must follow strict DDD, be easy to maintain, and support future agents with minimal code changes.
Feature Matrix: What Each Agent Needs
Claude Code (~/.claude/ + ~/.claude.json)
| Category | Host Path | Kind | Notes |
|---|---|---|---|
| settings | ~/.claude/settings.json |
File | Global settings (permissions, hooks, model, etc.) |
| settings | ~/.claude.json |
MergeFile | User MCP servers only — strip OAuth, project state |
| instructions | ~/.claude/CLAUDE.md |
File | Global user instructions |
| rules | ~/.claude/rules/ |
Directory | Glob-scoped rules with YAML frontmatter |
| agents | ~/.claude/agents/ |
Directory | Custom agent personas (*.md) |
| skills | ~/.claude/skills/ |
Directory | Skill bundles (SKILL.md + supporting files) |
| commands | ~/.claude/commands/ |
Directory | Legacy slash commands (*.md) |
Codex CLI (~/.codex/ + ~/.agents/)
| Category | Host Path | Kind | Notes |
|---|---|---|---|
| settings | ~/.codex/config.toml |
MergeFile | Strip [auth] section |
| instructions | ~/.codex/AGENTS.md |
File | Global instructions |
| instructions | ~/.codex/AGENTS.override.md |
File | Override instructions |
| skills | ~/.agents/skills/ |
Directory | Shared skills location |
| commands | ~/.codex/prompts/ |
Directory | Legacy custom commands |
OpenCode (~/.config/opencode/ + Claude Code compat paths)
OpenCode lives at github.com/anomalyco/opencode. It has built-in Claude Code compatibility: it reads ~/.claude/CLAUDE.md as an instructions fallback, searches ~/.claude/skills/ for skills, and also searches .agents/skills/ (shared with Codex). These are controllable via OPENCODE_DISABLE_CLAUDE_CODE, OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, and OPENCODE_DISABLE_EXTERNAL_SKILLS.
| Category | Host Path | Kind | Notes |
|---|---|---|---|
| settings | ~/.config/opencode/opencode.json |
MergeFile | JSONC format; strip provider API keys |
| settings | ~/.config/opencode/tui.json |
File | TUI config (themes, keybinds) |
| instructions | ~/.config/opencode/AGENTS.md |
File | Global instructions |
| instructions | ~/.claude/CLAUDE.md |
File | Claude Code compat fallback (read when no AGENTS.md) |
| agents | ~/.config/opencode/agents/ |
Directory | Custom agent personas |
| skills | ~/.config/opencode/skills/ |
Directory | Skill bundles |
| skills | ~/.claude/skills/ |
Directory | Claude Code compat — OpenCode searches this |
| skills | ~/.agents/skills/ |
Directory | Shared skills location (also used by Codex) |
| commands | ~/.config/opencode/commands/ |
Directory | Custom commands |
| tools | ~/.config/opencode/tools/ |
Directory | Custom tools (TypeScript) |
| plugins | ~/.config/opencode/plugins/ |
Directory | Plugins (TypeScript) |
| themes | ~/.config/opencode/themes/ |
Directory | Theme JSON files |
Deferred: Claude Code Memory
~/.claude/projects/<encoded-path>/memory/ uses path encoding based on the host workspace path. Inside the VM, the workspace is at /workspace, so the encoded path differs. Remapping requires understanding Claude Code's undocumented encoding scheme. Defer to a follow-up.
Implementation Plan
Phase 1: Domain Types — pkg/domain/settings/
New file: pkg/domain/settings/settings.go
Pure value objects and interface. Zero I/O.
// EntryKind classifies how a settings entry is injected.
type EntryKind int
const (
KindFile EntryKind = iota // Copy single file
KindDirectory // Recursive directory copy
KindMergeFile // Parse, filter fields, merge into existing guest file
)
// FieldFilter defines an allowlist of top-level keys to extract from a config file.
// Only keys in AllowKeys are copied. Security: denylist would leak new sensitive keys.
type FieldFilter struct {
AllowKeys []string // Top-level keys to include (empty = copy all)
DenySubKeys map[string][]string // Sub-keys to strip within allowed keys (e.g. "providers": ["*.apiKey"])
}
// Entry describes a single item to inject from host to guest.
type Entry struct {
Category string // "settings", "instructions", "rules", "agents", "skills", "commands", "tools", "plugins", "themes"
HostPath string // Relative to $HOME on host
GuestPath string // Relative to guest home (usually same as HostPath)
Kind EntryKind
Optional bool // Skip silently if source missing (true for all settings entries)
Filter *FieldFilter // Only for KindMergeFile
Format string // Only for KindMergeFile: "json", "toml", "jsonc"
}
// Manifest is the per-agent list of settings to inject.
type Manifest struct {
Entries []Entry
}
// Safety limits (mirrors credential package pattern)
const (
MaxFileSize int64 = 1 << 20 // 1 MiB per file
MaxTotalSize int64 = 50 << 20 // 50 MiB aggregate
MaxFileCount = 500
MaxDepth = 8
)
// Injector copies filtered settings from the host into the guest rootfs.
type Injector interface {
Inject(rootfsPath, hostHomeDir string, manifest Manifest) error
}New file: pkg/domain/settings/filter.go — Pure FilterEntries function for category filtering.
Phase 2: Extend Existing Domain Types
pkg/domain/agent/agent.go— AddSettingsManifest *settings.Manifestfield toAgentstructpkg/domain/vm/vm.go— AddSettingsManifest *settings.ManifesttoVMConfigpkg/domain/config/config.go— AddSettingsImportConfigandSettingsCategoryConfigtypes withIsCategoryEnabled(). Add toConfigandAgentOverridestructs. Merge rules: workspace-local.broodbox.yamlcan only disable (tighten-only), same asAuthandMCP.Authz.
Phase 3: Infrastructure — Settings Injector
New package: internal/infra/settings/
FSInjector implements settings.Injector. Pattern mirrors internal/infra/credential/store.go.
Behavior per EntryKind:
KindFile: Copy file, validate size, 0600 perms, chown 1000:1000, skip symlinksKindDirectory: Recursive copy with safety limits (MaxDepth, MaxFileCount, MaxFileSize, MaxTotalSize), skip symlinks, path containment checksKindMergeFile: Parse (JSON/TOML/JSONC), apply allowlist filter, strip deny sub-keys, deep-merge into existing guest file, write back
Also: stripJSONC() helper for OpenCode's JSONC format, and comprehensive table-driven tests.
Phase 4: Rootfs Hook
New file: internal/infra/vm/settingshook.go — InjectSettings hook factory following InjectCredentials pattern.
Phase 5: Fix MCP Deep-Merge (Critical Prerequisite)
Modify: internal/infra/vm/mcpconfig.go
Current mergeJSONKey replaces the entire value for a top-level key. When settings injection writes user's MCP servers into mcpServers in .claude.json, the MCP hook would overwrite them all with just sandbox-tools.
Fix: deep-merge into the mcpServers/mcp_servers/mcp maps instead of replacing. Add deepMergeJSONMapKey helper.
Phase 6: Hook Ordering in VM Runner
Modify: internal/infra/vm/runner.go
Insert InjectSettings between credentials and MCP:
- SSH keys + init binary + env file + git config
InjectCredentialsInjectSettings(NEW)InjectMCPConfig
Add settingsInjector settings.Injector field and WithSettingsInjector option to MicroVMRunner.
Phase 7: Agent Registry Manifests
Modify: internal/infra/agent/registry.go
Populate SettingsManifest for each built-in agent with the entries from the feature matrix above. This is the key data declaration — adding a future agent only requires declaring its manifest entries here.
Note on OpenCode: The OpenCode manifest includes Claude Code compatibility paths (~/.claude/CLAUDE.md, ~/.claude/skills/) alongside native OpenCode paths. These are all Optional: true, so if the user doesn't have Claude Code installed, the entries are silently skipped.
Phase 8: Application Layer Pass-Through
Modify: pkg/sandbox/sandbox.go — Add resolveSettingsManifest method (follows resolveMCPConfig pattern), filter entries by enabled categories, pass to VMConfig.
Phase 9: Composition Root Wiring
Modify: cmd/bbox/main.go — Wire FSInjector, add --no-settings CLI flag, config handling.
Modify: pkg/runtime/factory.go — Wire default settings injector.
Config Schema
# ~/.config/broodbox/config.yaml
settings_import:
enabled: true # Default: true. --no-settings overrides.
categories:
settings: true # Config files (settings.json, config.toml, etc.)
instructions: true # CLAUDE.md, AGENTS.md
rules: true # rules/*.md (Claude Code)
agents: true # Agent persona files
skills: true # Skills directories
commands: true # Slash command files
tools: true # Custom tools (OpenCode)
plugins: true # Plugins (OpenCode)
themes: true # Themes (OpenCode)
# Per-agent override:
agents:
claude-code:
settings_import:
categories:
skills: false # Disable skills for Claude Code onlySecurity Considerations
- Allowlist for MergeFile fields — Only explicitly listed top-level keys are copied. New unknown keys in agent config updates are safely ignored. Prevents credential leakage from evolving config formats.
- Tighten-only from workspace config —
.broodbox.yamlcan only disable settings import, not enable it. Prevents malicious repos from enabling injection to exfiltrate host data. - Filter specs are non-configurable —
AllowKeysandDenySubKeysare hardcoded in the agent manifest. A workspace cannot modify what gets stripped. - Size limits — 1 MiB per file, 50 MiB aggregate, 500 files max, 8 levels depth.
- Symlink rejection —
os.Lstat+ skip symlinks to prevent reading arbitrary host files. - Path containment — All paths validated to stay under rootfs sandbox home, reusing
containedPathpattern. - File permissions — All injected files written with 0600, chown to 1000:1000.
Files Summary
| File | Action | Description |
|---|---|---|
pkg/domain/settings/settings.go |
New | Domain types: Entry, Manifest, FieldFilter, Injector interface, constants |
pkg/domain/settings/filter.go |
New | Pure FilterEntries function |
pkg/domain/agent/agent.go |
Modify | Add SettingsManifest *settings.Manifest field |
pkg/domain/vm/vm.go |
Modify | Add SettingsManifest *settings.Manifest to VMConfig |
pkg/domain/config/config.go |
Modify | Add SettingsImportConfig, SettingsCategoryConfig, merge rules |
internal/infra/settings/injector.go |
New | FSInjector: file copy, dir copy, merge-file with field filtering |
internal/infra/settings/jsonc.go |
New | JSONC comment stripping |
internal/infra/settings/injector_test.go |
New | Comprehensive table-driven tests |
internal/infra/vm/settingshook.go |
New | InjectSettings rootfs hook factory |
internal/infra/vm/mcpconfig.go |
Modify | Deep-merge MCP server keys instead of replacing |
internal/infra/vm/runner.go |
Modify | Add settingsInjector field, WithSettingsInjector option, hook insertion |
internal/infra/agent/registry.go |
Modify | Populate SettingsManifest for all 3 agents |
pkg/sandbox/sandbox.go |
Modify | resolveSettingsManifest, pass to VMConfig |
cmd/bbox/main.go |
Modify | Wire injector, add --no-settings flag, config handling |
pkg/runtime/factory.go |
Modify | Wire default settings injector |
pkg/domain/config/config_test.go |
Modify | Test settings merge (tighten-only) |
Adding a New Agent in the Future
To add a new coding agent, a developer only needs to:
- Add the agent to
internal/infra/agent/registry.go(already required) - Populate its
SettingsManifestwith the appropriateEntryitems - No new code paths needed — the framework handles all injection
This is the same pattern as CredentialPaths and MCPConfigFormat — declarative data on the agent value object, with generic infrastructure that operates on it.