Skip to content

Implement per-agent environment isolation per docs/per-agent-environment-design.md#13

Merged
orveth merged 2 commits into
masterfrom
agent-isolation-impl
May 27, 2026
Merged

Implement per-agent environment isolation per docs/per-agent-environment-design.md#13
orveth merged 2 commits into
masterfrom
agent-isolation-impl

Conversation

@orveth

@orveth orveth commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the per-agent environment isolation contract from
docs/per-agent-environment-design.md
(the spec branch / PR #12 as the design contract).

PR #11 already landed the runtime scaffolding (per-agent state dir,
tmux wrapper, systemd service, --effort max). This PR layers the
declarative library + per-agent whitelist + new exec recipe on top.

What landed (6 steps)

  1. Library option families — three new modules, each declaring its
    option schema only:

    • modules/skills.nixservices.forge.skills.<name> = { source; description; }
    • modules/mcp-servers.nixservices.forge.mcpServers.<name> = { command; args; env; }
    • modules/plugins.nixservices.forge.plugins.<name> = { source; }

    All three are imported from modules/default.nix.

  2. Library installation in modules/harnesses/claude-code.nix:

    • skills -> /etc/forge/skill-library/<name>/ via environment.etc
    • plugins -> /etc/forge/plugin-library/<name>/ via environment.etc
    • MCP servers don't need an install path (transcribed into the per-agent .mcp.json).
    • Defensive uniqueness assertions for each family.
  3. Per-agent whitelist + permissions schema on services.forge.agents.<name>
    (extends modules/agents.nix):

    • skills, mcpServers, plugins, allowedTools (each listOf str, default [])
    • permissions = { allow = []; deny = []; skipPrompts = true; }
      (schema only — skipPrompts reserved for the scoped-permissions follow-up)
    • Cross-cutting assertions: every name referenced from an agent must
      resolve in the corresponding library family.
  4. Per-agent CWD assembly on every wrapper start:

  5. New exec recipe per the spec's "The exec recipe" section:

    • --bare intentionally dropped (would break OAuth subscription auth)
    • --strict-mcp-config intentionally dropped (would strip plugin-bundled MCPs)
    • --mcp-config .mcp.json
    • --plugin-dir /etc/forge/plugin-library/<name> per declared plugin
    • --settings .claude/settings.json + --setting-sources project
    • --append-system-prompt-file .claude/skill-catalog.md
    • --dangerously-skip-permissions
    • --allowedTools "<joined>" only when non-empty (empty would strip built-in tools)
    • Old hasDiscord + --channels "plugin:discord@..." line removed; discord
      flows through services.forge.plugins.discord in v2.
  6. Demo agent migrated in deployments/agicash-team-forge/configuration.nix:
    the example block now declares the shared library (skills / mcpServers /
    plugins) plus the agent's whitelist + allowedTools, per the spec example
    shape. Kept fully commented-out until the operator runs the sops bootstrap.

Deferred (named in the spec's Non-goals)

  • nix develop .#agent.<name> devShell output (step 7 from spec) — defer
  • disallowedTools beyond schema — defer
  • Per-agent Linux user — defer (current design has agents on same runAs)
  • Plugin/comms split (services.forge.comms.<name>) — defer
  • Hooks family (PreToolUse / PostToolUse) — defer
  • Library namespacing — defer (single-host single-team for now)
  • harness portability fields — defer

Validation

Run before opening this PR:

  • nix flake check passes (with a placeholder deploy-config.nix)
  • nix eval --raw .#nixosConfigurations.agicash-team-forge.config.system.build.toplevel.drvPath
    resolves to a valid system derivation
  • Eval test instantiating a config with a declared skill / plugin / MCP
    server confirms environment.etc."forge/skill-library/<name>".source
    and .../plugin-library/<name>".source resolve to store paths
  • Generated wrapper script passes bash -n for both the
    full-whitelist case and the empty-whitelist (no skills / mcps / plugins
    / allowedTools) case
  • Negative test: misspelled skill name in an agent triggers the
    services.forge.agents.<name>.skills references "<typo>" but ... is not declared in services.forge.skills assertion with the
    expected message

Test plan

  • Operator does the sops bootstrap (docs/secrets-bootstrap.md) and
    uncomments the demo block in
    deployments/agicash-team-forge/configuration.nix
  • deploy-rs rolls out the change; systemctl status forge-agent-coordinator
    reports active
  • Attach via sudo -u gudnuf tmux -L forge attach -t agent-coordinator
    and exercise the spec's validation criteria (skill isolation, MCP
    isolation, plugin MCPs load, no user-level skill/MCP/settings leak)

🤖 Generated with Claude Code

orveth and others added 2 commits May 27, 2026 11:06
Extends the claude-code harness with declarative library option families
and per-agent whitelists per docs/per-agent-environment-design.md
(spec on per-agent-isolation-design / PR #12).

What lands:

1. Three library option families:
   - services.forge.skills.<name>      { source; description; }
   - services.forge.mcpServers.<name>  { command; args; env; }
   - services.forge.plugins.<name>     { source; }

2. Library installation in modules/harnesses/claude-code.nix:
   - skills  -> /etc/forge/skill-library/<name>/   via environment.etc
   - plugins -> /etc/forge/plugin-library/<name>/  via environment.etc
   - mcpServers stay in-config (referenced into per-agent .mcp.json)
   - Eval-time assertions for library uniqueness.

3. Per-agent whitelist + permissions schema on services.forge.agents.<name>:
   - skills, mcpServers, plugins, allowedTools  (listOf str, default [])
   - permissions { allow; deny; skipPrompts = true; }
   - Cross-cutting assertions: every reference must resolve in the library.

4. Per-agent CWD assembled on every start:
   - CLAUDE.md (from role) — already there
   - .claude/skills/<name> symlinks to /etc/forge/skill-library/<name>
   - .claude/settings.json from permissions.{allow,deny}
   - .claude/skill-catalog.md from declared skills' descriptions
   - .mcp.json from declared mcpServers (always written, even empty)
   - .env (discord token) — already there

5. New exec recipe per spec section "The exec recipe":
   - --bare dropped (breaks OAuth)
   - --strict-mcp-config dropped (strips plugin-bundled MCPs)
   - --mcp-config, --plugin-dir-per-plugin, --settings,
     --setting-sources project, --append-system-prompt-file,
     --allowedTools (only when non-empty)
   - Old --channels conditional removed; the discord plugin now flows
     through services.forge.plugins.

6. Demo agent in deployments/agicash-team-forge/configuration.nix
   updated to the new shape (kept commented-out until operator runs
   the sops bootstrap).

Deferred per spec "Non-goals":
- nix develop .#agent.<name> devShell output
- disallowedTools beyond schema
- Per-agent Linux user
- Plugin/comms split
- Hooks family
- Library namespacing
- harness portability fields

Validation run before pushing:
- nix flake check passes
- nix eval .#nixosConfigurations.agicash-team-forge.config.system.build.toplevel.drvPath
  resolves to a valid derivation
- Eval test with declared skill/plugin/mcp confirms environment.etc
  entries resolve to store paths
- Generated wrapper passes bash -n; empty-whitelist agent still
  evaluates and produces a valid script
- Negative test: misspelled skill reference fires the assertion
  with the expected message

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Technical review surfaced one HIGH-priority security issue and one
refuted spec claim. Design review surfaced three small consistency
items. All amendments below; flake check passes.

HIGH (technical A): Shell injection via interpolated Nix fields.
Wrap shell-interpolated values with lib.escapeShellArg in:
- plugin path construction (pluginDirArgs)
- skill symlink targets + names
- discord bot tokenFile read
- agent.model in the exec line
Threat model is "trusted Nix config author," but defense in depth
matters when modules can be mkMerge'd from untrusted sources.

REFUTED (technical B): --allowedTools has no observable effect on
the agent's tool surface in claude-code 2.1.150 when
--dangerously-skip-permissions is set. Empirical: with
--allowedTools "Read,Bash(git *)", claude still reports Edit, Write,
Glob, Grep, Agent, Task, etc. The actual tool-restriction flag is
--tools (kept off — agents need full default tools today). Drop
--allowedTools from the exec recipe. The agent.allowedTools schema
field stays — it lands in settings.json for when scoped permissions
are designed.

DEAD CODE (technical D): Drop the three "library uniqueness"
assertions in claude-code.nix. Attrset names are structurally unique
in Nix; the substantive cross-reference assertions (agent.skills
must reference a declared skill, etc.) live in modules/agents.nix
and stay.

CONSISTENCY (design 1, 2, 3):
- Skill catalog separator: em-dash → colon (matches spec line 187)
- Submodule shape: drop unused `({ name, ... }: ...)` from
  skills.nix and plugins.nix to match mcp-servers.nix + discord.nix
- Demo `coordinator` exercises all three library families: add
  services.forge.mcpServers.playwright entry to the commented demo
  block and append "playwright" to coordinator.mcpServers

Validation: `nix flake check` passes (with a temporary
deploy-config.nix for eval; not committed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@orveth orveth merged commit fa51a89 into master May 27, 2026
@orveth orveth deleted the agent-isolation-impl branch May 27, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant