Skip to main content
A self-healing agent reads its own failure data and rewrites its own operating rules — both are attack surfaces. The harness draws four explicit trust boundaries.

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. TaskToolset is constructed from TASK_OP_SCHEMAS alone, so an administrative name has no handler to reach. A hallucinated harness_rule_add returns {"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_add creates 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).
This applies to platform 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_policy you 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/passwd would produce the perfectly safe etc-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, and jq.
  • 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, and jq are 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, never shell=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. 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 your repair_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 pandaprobe CLI 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_model is explicit or construction fails.
  • Ask a human to approve a rule. It will stop asking itself: the circuit breaker’s needs_human notice pauses self-healing when notice volume looks pathological, for an operator to pick up.