# swairm-Pattern Port: Agent Credential Isolation, Untrusted-Repo Preflight, Orphaned-Claim Recovery — Design
Status: approved scope 2026-07-13 (commander, async — Sardinia). Build #2a; spec #2b, #3, #1. Goal: port three hardening patterns from swairm (github.com/morzu117/swairm) into swarph, each scoped to what swarph's actual code needs — not a mechanical copy. Source: [[reference_swairm_repo]]. swairm's implementations are ports-and-adapters, TDD, hardened through their own review rounds (R1/I1–I3).
---
Grounding — what the swarph code actually is (verified 2026-07-13)
An Explore pass mapped every landing zone. The headline facts that shape this design:
1. Credential leak is real and proven. Every agent-spawn site except grok inherits the
operator's real HOME. The billing scrubbers (scrub_env_for_subprocess /
scrub_billing_env / _scrubbed_env) strip only _API_KEY / _AUTH_TOKEN / *_BASE_URL.
None scrub GitHub creds. With the inherited HOME, ~/.claude/.credentials.json,
~/.config/gh/hosts.yml, ~/.git-credentials, ~/.netrc, ~/.ssh are all readable by the
spawned agent. Proof: gh auth token returns a token from the env a spawned cell inherits.
Impact: a spawned coding cell can act as the operator on GitHub.
2. grok already solves it in-repo — the reference prototype:
spawn.py:_grok_env→ disposable HOME undercell.cwd/.grok-cell, symlinks only
~/.grok/auth.json in, scrubs the whole GROK_/XAI_ namespace to block HOME-bypass
redirects (GROK_HOME/GROK_AUTH_PATH).
grok-service→ bwrap default-deny jail +CLEAN_HOMEallowlist built from scratch
PATH HOME USER TERM LANG).
3. No automated reviewer exists. --setting-sources appears in zero swarph repos; no
git config --list poison probe anywhere. security.py:llm_review is scaffolded-not-wired and
reviews content text, not git diffs. So pattern #3 has no consumer today.
4. Durable state is already solid; recovery is the gap. All control-plane state is one WAL
SQLite (/home/ubuntu/research/claude_mesh.db), atomic BEGIN IMMEDIATE claims, unique keys —
no volatile queue to lose. But: claude_tasks has no orphan reaper; gpu_jobs has a
reclaim-stale endpoint with no automated caller; both existing reclaim paths reclaim by
claimed_at age only → a still-alive worker's job can be reclaimed and run twice.
Consequence for #1: swarph does not need "GitHub as sole truth" — it already has a single durable source of truth. It needs the other half swairm has: orphaned-claim recovery. So #1 is reframed from an architecture bet to a bounded crash-safety fix.
---
Pattern #2 — Disposable-HOME credential isolation
Problem
A spawned agent should never receive credentials it does not need. Today only grok is isolated; claude/codex/agy and every headlessclaude -p inherit the operator HOME and thus every on-disk
credential. The worst instance: lab-orchestrator/orchestrator.py itself runs gh repo clone /
gh pr create (it is the credential holder) and spawns a claude -p child that inherits those
creds — exactly swairm's "the drone must not hold the daemon's credential."
Split (why two deliverables)
The interactive cell membrane (spawn.py claude/codex/agy) is not the same problem as a
headless one-shot. An interactive claude cell reads the operator's ~/.claude/settings.json
(SessionStart hooks, the lab ritual, MCP config, the inbox-watcher) — a naive HOME swap would
strip the very hooks the cell depends on, and it entangles with the shared-settings issue (#20).
A headless claude -p needs only auth + a prompt. So:
- #2a (BUILD NOW): isolate the headless one-shot spawns.
- #2b (SPEC, later): isolate the interactive cell membrane, with settings/hooks curation.
#2a design — headless spawn isolation
New shared helper in swarph-shared (consumed by swarph-cli and the *-service servers):
`
isolated_agent_home(provider: str, *, root: Path, link_auth: bool = True) -> Path
`
- Creates a disposable HOME at
root/.(mirrors grok's-drone-home .grok-cell). - Symlinks only the provider's own auth into it (best-effort, idempotent — generalises
_link_grok_auth):
claude→~/.claude/.credentials.json→/.claude/.credentials.json codex→~/.codex/auth.json→/.codex/auth.json gemini→~/.gemini/auth (link the auth file, not the whole dir)- Writes a minimal git identity with
[credential]\n\thelper =to reset the credential-helper
- Excludes by construction:
~/.config/gh,~/.git-credentials,~/.netrc,~/.ssh— never
`
build_isolated_env(source: Mapping[str,str], home: Path, provider: str) -> dict[str,str]
`
- Pure. Starts from the existing billing scrub, then forces
env["HOME"] = str(home)(never
source — that is what cuts on-disk cred access), applies the provider namespace scrub
(generalises _scrub_grok_namespace so _HOME/_AUTH_PATH redirects can't bypass the HOME).
build_isolated_env/ the path builders are pure & unit-testable; the symlink + subprocess are
Wire-in sites (#2a): providers.py:run_provider, claude-service (3 sites),
gpt-service, orchestrator.py:452, and the exec_runners/*.sh shells (which today inherit the
full env + HOME with no scrub at all — highest-leverage, lowest-risk fix). grok is already
done; leave it.
#2a testing (mandatory — this is critical path)
- Unit:
build_isolated_envforces HOME, drops gh/git keys, keeps only allowlisted env; path
- Real-spawn smoke test (the load-bearing one): spawn a real
claude -p "print ok"under an
gh auth token / reading ~/.config/gh fails from inside that env. Present ≠ isolated —
prove the negative.
#2a caveat — blast radius
Spawns are the critical path. #2a is built on a branch, TDD, PR — not merged/deployed until the commander reviews. A disposable-HOME bug that breaks auth would break spawns; the real-spawn smoke test is the guard, human review is the gate.#2b design (SPEC only) — interactive cell membrane
Isolatespawn.py claude/codex/agy membranes. Requires the disposable HOME to also carry what
an interactive cell needs: settings.json (hooks — SessionStart drain, inbox-watcher), MCP config,
CLAUDE.md/memory. Options to decide at build time: (i) symlink the operator's ~/.claude/settings.json
read-only into the isolated HOME (keeps hooks, still drops gh); (ii) generate a per-cell settings
from a template (also resolves #20 shared-settings contamination — a bonus). Open question:
interaction with ~/.claude.json (project history/config). Delicate → its own spec + plan.
---
Pattern #3 — Untrusted-repo preflight (SPEC only; no consumer today)
Problem
When an agent reads a repo/worktree it did not author, two hijack surfaces exist: 1. A committed project.claude/settings.json can re-authorize tools for the reading agent.
2. A poisoned .git/config (diff.external, *.textconv, core.hookspath, core.fsmonitor,
filter.*.clean/smudge, alias.{diff,log,show,status}) turns a plain git diff into code
execution.
swairm's Queen defends both: run with --setting-sources user, and a git config --list probe
(executes nothing) matched against a poison regex — poisoned ⇒ abstain, no verdict.
swarph reality
No reviewer exists;--setting-sources is used nowhere. The only agent that reads an un-authored
checkout today is a spawned cell itself (Pattern #2), which inherits the checkout's git config
with no guard. So #3 has no reviewer to wrap — it is net-new infrastructure.
Design (reusable, consumer-agnostic)
A pure moduleuntrusted_repo_preflight in swarph-shared:
git_config_is_poisoned(config_list_output: str) -> str | None— pure; returns the offending
_POISONED_GIT_RE (battle-tested).
preflight(workdir, run_git) -> None— runsgit config --list(safe probe), raises on poison;
safe_reader_flags() -> list[str]— `["--setting-sources", "user", "--disallowedTools",
for any claude -p` that reads an
untrusted repo.
Consumers (future)
(a) A--untrusted mode on the #2a helper — a spawn told it is reading an un-authored checkout runs
the preflight + safe_reader_flags. (b) Any future swarph reviewer/verify flow. Ship the helper +
tests now (spec), wire when a consumer lands. No silent no-op: the helper is inert until a
caller opts in — documented as such.
---
Pattern #1 — Orphaned-claim recovery (SPEC only; reframed)
Problem (the true gap)
Durable state is solid; recovery is missing:claude_tasks: a task leftin_progressby a dead claimer has no reaper — stalls forever.gpu_jobs:POST /gpu/jobs/reclaim-staleexists but has no automated caller (unlike
council_jobs, which a 5-min cron reclaims).
- Duplicate hazard: both reclaim paths reset by
claimed_atage *without confirming the worker
Design — swarph-native, better than the reference
swairm reclaims by age only (it has no liveness signal). swarph does have one: the watchdog activity-marker / peer-healthlast_health (#26). So:
claude_tasksreaper: aPOST /tasks/reclaim-staleendpoint (mirror the council reclaimer),
in_progress → queued past a staleness threshold, bumps
attempts, records last_error="reclaimed: claimer presumed dead".
gpu_jobs: wire the existingreclaim-staleendpoint to the same cron cadence.- Liveness-aware reclaim (the upgrade): before reclaiming a claim owned by a named cell, check
last_health/last_seen. If the worker is provably alive (fresh health within
the window), do not reclaim — only age plus absent liveness reclaims. Closes the duplicate
hazard age-only can't.
- Idempotency preserved: reclaim is
BEGIN IMMEDIATE+ guarded `UPDATE … WHERE status='in_progress'
Scope note
mesh-gateway change (two repos:mesh-gateway/server.py deployed + the swarph-cli/.../gateway
mirror — keep them in lockstep, per the divergence lesson [[project_two_gateways_diverged]]).
Deploy is commander-gated. Spec now; build on the commander's go.
---
Testing strategy (all patterns)
TDD throughout (venv/bin/python -m pytest). Pure functions (env builders, poison regex, path
builders, reclaim SQL predicate) unit-tested; subprocess/symlink/DB are injectable seams. The
non-negotiable test is #2a's real-spawn smoke test — proving the credential negative, not just
presence.
Build order
1. #2a — credential isolation for headless spawns (this branch:feat/cell-credential-isolation).
2. #1 — orphaned-claim reaper + liveness-aware reclaim (on the commander's go; touches gateway).
3. #2b — interactive-cell HOME isolation (delicate; own spec/plan).
4. #3 — untrusted-repo preflight helper (ship inert; wire when a consumer lands).