Capability separation
The load-bearing boundary is that the two agents have different powers. The developer-owned task agent gets four read-only tools and nothing else; the package-owned repair agent is the only thing that can write to the workspace.
Two properties make this more than a naming convention:
- The task surface is built from an allow-list, not filtered by a check.
TaskToolsetis constructed fromTASK_OP_SCHEMASalone, so an administrative name has no handler to reach. A hallucinatedharness_rule_addreturns{"ok": false, "error": "unsupported capability 'harness_rule_add'"}— rejected at dispatch, with no side effect. - The repair agent cannot promote its own rule.
harness_rule_addcreates a candidate; only the validation engine transitions it. A repair agent that decided its own rule was good would be a self-approving loop, so that capability does not exist for either agent.
Prompt-injection boundary
Evaluation output is derived from traces, and traces contain whatever your users typed. Everything eval-derived that can reach agent context crosses one sanitization gate (sanitize_text):
- ANSI escape and control sequences stripped.
- Banner runs (
=====,-----,#####) collapsed, so injected text can’t fake the harness’s own framing. - The harness’s trusted marker phrases (
PANDAPROBE HARNESS,SYSTEM ALERT,harness:) neutralized with an interposed character. - Length capped (
sanitize_max_len, default 2000).
reason strings, notice summaries, eval-case notes, host-supplied task_summary and scope descriptions, and — critically — repair-authored rule text and rationale, since rules re-enter every future run through the files the task agent reads.
Three structural defenses back it up:
- The task preamble is a constant. It is a fixed capability sentence naming four tools — no rule bodies, no scope index, no notice content, no counts. Building it reads nothing, so there is no path by which eval-derived text reaches a task prompt automatically.
- A poisoned rule’s blast radius is one file the agent chose to read, not “every prompt, always”.
- The repair prompt frames its inputs as data. Notice, trace, dump, task-summary, scope-description, and domain-policy text are all declared untrusted, never as instructions. A
domain_policyyou supply can describe authorized behavior but never expands the repair agent’s tools.
Path safety
Agents supply identifiers that become filenames. Notice ids and eval-case ids are validated as single safe path components ([A-Za-z0-9._-], no ..), so a crafted id like ../../state/score_history cannot escape the mailbox or eval-set directories.
Rule scopes are the same class of input, and now come from a model. A scope must be one safe path component ([a-z0-9][a-z0-9._-]{0,47}), and rules_scope_file() only ever resolves inside rules/.
Two deliberate choices here:
- A path-shaped scope is rejected, not repaired. Slugifying
../../etc/passwdwould produce the perfectly safeetc-passwd— and file a rule under a name nobody chose. The write fails with an error the repair model can see and correct. - A generic host label is refused. A benchmark or integration’s own name for itself describes where the agent ran, not what failed, so it cannot become a scope.
The restricted shell
RestrictedShellTool and ShellPolicy remain available for sandboxed or framework-less integrations. They are no longer attached to the facade — there is no Harness.shell, because the task agent has no execution surface to grant.
- Allow-listed binaries only — by default
pandaprobe,cat,ls, andjq. - Credential scoping — credential-shaped environment variables (
*API_KEY*,*SECRET*,*TOKEN*,PANDAPROBE_*, …) are scrubbed from every subprocess and restored only for binaries that talk to the platform.cat,ls, andjqare allow-listed but never see them. - Argv policy — denied subcommand sequences (
pandaprobe config,pandaprobe auth login/logout) match as ordered subsequences, so leading global flags can’t bypass them; denied flags (--reveal-secrets) match with or without=value; no shell metacharacters (shlex+exec, nevershell=True). - Workspace confinement — path arguments may not escape the workspace, including mid-path traversal (
state/../../etc/passwd).
Trace isolation
Repair activity must never be scored as task activity, or the harness would grade itself. Repair model calls run in a fresh async context under a distinct SDK session,repair-<task-session-id>-<episode-id>, and exact-session task-trace discovery excludes them. With trace_repair_agent=true each repair run exports one pandaprobe trace whose harness CHAIN span holds repair-agent and tools AGENT spans; with it disabled the SDK context is non-exporting, so the LiteLLM wrapper cannot create an accidental standalone trace.
Container isolation (recommended)
Run the agent with a read-only filesystem except a volume at the workspace root, and an egress allowlist limited to the PandaProbe endpoint and whatever provider yourrepair_model needs. The repository ships a reference sandbox (docker-compose.yml, Dockerfile.sandbox):
What the harness will never do
- Talk to the REST API directly — all platform access is the
pandaprobeCLI subprocess, so authentication, TLS, and endpoint policy have exactly one implementation. - Push content into your agent’s message queue — rule delivery is always pull, through four read-only tools.
- Force rule content into your agent’s context — the
rules/subtree is read on demand, never injected, and nothing is read before a task turn. - Let the task agent administer the workspace, or let either agent promote a rule. Evidence promotes rules; agents do not.
- Select a billable model for you —
repair_modelis explicit or construction fails. - Ask a human to approve a rule. It will stop asking itself: the circuit breaker’s
needs_humannotice pauses self-healing when notice volume looks pathological, for an operator to pick up.

