Implement per-agent environment isolation per docs/per-agent-environment-design.md#13
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thedeclarative library + per-agent whitelist + new exec recipe on top.
What landed (6 steps)
Library option families — three new modules, each declaring its
option schema only:
modules/skills.nix—services.forge.skills.<name> = { source; description; }modules/mcp-servers.nix—services.forge.mcpServers.<name> = { command; args; env; }modules/plugins.nix—services.forge.plugins.<name> = { source; }All three are imported from
modules/default.nix.Library installation in
modules/harnesses/claude-code.nix:/etc/forge/skill-library/<name>/viaenvironment.etc/etc/forge/plugin-library/<name>/viaenvironment.etc.mcp.json).assertionsfor each family.Per-agent whitelist + permissions schema on
services.forge.agents.<name>(extends
modules/agents.nix):skills,mcpServers,plugins,allowedTools(eachlistOf str, default[])permissions = { allow = []; deny = []; skipPrompts = true; }(schema only —
skipPromptsreserved for the scoped-permissions follow-up)resolve in the corresponding library family.
Per-agent CWD assembly on every wrapper start:
CLAUDE.md(role text — already from PR Add claude-code harness runtime — agents into running processes #11).claude/skills/<name>symlinks per declared skill (wiped + recreated each start).claude/settings.jsongenerated frompermissions.{allow,deny}.claude/skill-catalog.mdgenerated from skills' descriptions (always written).mcp.jsongenerated from declared MCP servers (always written, even{"mcpServers":{}}).env(discord token plumbing — kept intact from PR Add claude-code harness runtime — agents into running processes #11)New exec recipe per the spec's "The exec recipe" section:
--bareintentionally dropped (would break OAuth subscription auth)--strict-mcp-configintentionally 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)hasDiscord+--channels "plugin:discord@..."line removed; discordflows through
services.forge.plugins.discordin v2.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) — deferdisallowedToolsbeyond schema — deferrunAs)services.forge.comms.<name>) — deferValidation
Run before opening this PR:
nix flake checkpasses (with a placeholderdeploy-config.nix)nix eval --raw .#nixosConfigurations.agicash-team-forge.config.system.build.toplevel.drvPathresolves to a valid system derivation
server confirms
environment.etc."forge/skill-library/<name>".sourceand
.../plugin-library/<name>".sourceresolve to store pathsbash -nfor both thefull-whitelist case and the empty-whitelist (no skills / mcps / plugins
/ allowedTools) case
services.forge.agents.<name>.skills references "<typo>" but ... is not declared in services.forge.skillsassertion with theexpected message
Test plan
docs/secrets-bootstrap.md) anduncomments the demo block in
deployments/agicash-team-forge/configuration.nixdeploy-rsrolls out the change;systemctl status forge-agent-coordinatorreports active
sudo -u gudnuf tmux -L forge attach -t agent-coordinatorand exercise the spec's validation criteria (skill isolation, MCP
isolation, plugin MCPs load, no user-level skill/MCP/settings leak)
🤖 Generated with Claude Code