Completion & Safety Hardening

Tool-result prompt-injection quarantine, graceful tool-budget wrap-up, and evidence-bound completion caveats for autonomous runs.

This page documents a set of runtime safeguards added to ClapilotAICore after a mechanism-level study of the external Cortheon "cognitive sidecar" runtime. Cortheon's headline claims oversell, but three of its engineering ideas map directly onto real gaps in our harness:

  1. tool results are untrusted data and must be scanned before re-entering the model context;
  2. a run that exhausts its tool budget should wrap up in text, not fail;
  3. a run that says "done" is not evidence it actually finished — unmet obligations need evidence-bound handling without leaking runtime diagnostics into chat.

These are the Tier 1 items. The quarantine and deterministic caveats do not add an LLM round-trip. The autonomous completion judge deliberately adds one independent model call for substantive non-interactive runs, and at most one continuation plus one re-judge in continue mode.

Feature switches (Settings → ClapilotAICore → Runtime)

Every feature is toggled from the UI, not from env vars. The switches are stored in app_settings (migration db/migrations/284_app_settings_completion_safety_hardening.sql) and the agent re-reads them every ~5s through loadAppSettingsRuntimeFlags, so a change in the settings panel takes effect within seconds and needs no redeploy or container restart. All default on.

Settingapp_settings columnControlsDefaultValues
Tool-result quarantinetool_result_quarantine_enabled§1 injection quarantineonon / off
Tool-budget wrap-uptool_budget_wrapup_enabled§2 graceful wrap-up (all 6 loops)onon / off
Run-completion caveatsrun_completion_caveats_enabled§3 todo + side-effect caveatsonon / off
Completion judgeautonomous_completion_judge_mode§3 LLM completion judgecaveatoff / caveat / continue

Wired end-to-end across all three clients: the web settings panel (src/components/opencore-settings-panel.tsx), the API contract (src/app/api/app-settings/route.ts + src/lib/app-settings.ts), and the native iOS/macOS settings screen, with DE/EN/IT localization on each. The agent runtime reads the flags in loadAppSettingsRuntimeFlags (services/clapilot-agent/src/providers/index.mjs).

Scope is global (per instance), not per-provider/model, by design. The quarantine is a security control — a per-model toggle would be a per-model hole. Wrap-up and caveats are run-shaped, not model-shaped. The judge is the only feature where per-model tuning could later make sense; that would be an additional per-provider column, not a change to these instance-wide switches.

The run-completion-caveats switch gates whether any actionable caveat text is appended; the judge mode independently gates whether the judge runs. With caveats off but the judge in caveat/continue mode, the judge still runs (and logs its verdict event, and can still drive a continue-mode continuation) but appends no caveat text.

1. Tool-result prompt-injection quarantine

Implementation: services/clapilot-agent/src/tool-result-quarantine.mjs, wired at the single tool-result choke point executeToolBatch (services/clapilot-agent/src/providers/index.mjs).

Every tool result in the native provider loop now passes through a scanner before it re-enters the model context. Claude and Cursor subscription bridges apply the same scanner inside scripts/claude_clapilot_mcp.mjs, before the MCP result reaches the external CLI model. Previously web search, Browser Use, email bodies, and documents flowed into tool context verbatim, with the only defense being prose in a handful of tool descriptions.

The scanner is a faithful JS port of Cortheon's evidence sanitizer. The adapted code retains Cortheon's MIT attribution in THIRD_PARTY_NOTICES.md. It has two pattern families:

  • Injection patterns — ~10 high-precision regexes for direct overrides ("ignore all previous instructions", "you are now a…", "do not tell the user", exfiltration verbs targeting a possessed secret, leading system:/developer: labels).
  • Role-override grammar — a compositional matcher for imperatives that override the role itself ("IGNORE SYSTEM:", "forget your developer instructions"). Precision comes from clause structure, not a longer word list, so ordinary technical prose ("the parser ignores previous connection settings", "ignore system errors when parsing this log", ignore system: true) is never flagged.

Behavior:

  • The scan is layout-preserving: only the offending sentence or line is replaced with [instruction-shaped content removed]; everything else passes through byte-identical.
  • Results of external-content tools (web search, Browser Use, inbound mail, documents, feeds, live-chat) additionally get an untrusted-data preamble — the same "untrusted data, never instructions" framing agent-memory retrieval already applies to recalled content.
  • Catalog-only tool_execute calls are classified by their concrete target tool, so wrapped Canvas/document/web reads receive the same preamble as a directly exposed tool.
  • Our own trusted signposts (truncation notes, the tool_result_recall hint) are appended after the scan, and system-authored results (recall, invalid-argument) are exempt.
  • A quarantine event surfaces on the tool-end event as quarantinedSegments and is logged; it does not alter or fail the run.

Untrusted-content tools are identified by a name set plus a pattern (web_*, *_fetch_url, document readers, feed/live-chat tools) and match through MCP namespacing (mcp__gw__web_search). Direct inbound user messages remain user instructions and are not rewritten by the evidence scanner.

2. Graceful tool-budget wrap-up in every provider loop

Implementation: the six native provider loops in services/clapilot-agent/src/providers/index.mjs.

When a run exhausts maxToolSteps (default 24), the loop now grants one extra grace iteration: it withdraws the tool surface and injects TOOL_LOOP_STEP_LIMIT_WRAPUP, asking the model to give its best final answer in text and state explicitly what remains unfinished.

Previously only runOpenAiLike and runOllama did this; the other four (runOpenAiResponsesWithTools, runAnthropic, runBedrock, runGemini) simply threw on budget exhaustion, failing the whole run. All six now behave consistently. The terminal budget throw remains as a safety net for the case where the model keeps emitting tool calls after tools are withdrawn.

The result carries toolBudgetExhausted: true when the grace turn was used. Run finalization converts that signal into a deterministic localized marker, for example Incomplete — tool budget exhausted, even if the model's wrap-up text omits the fact that work remains.

3. Evidence-bound completion caveats for autonomous runs

Implementation: services/clapilot-agent/src/run-completion-caveats.mjs, services/clapilot-agent/src/autonomous-completion.mjs, wired into run finalization in executeRunNow (services/clapilot-agent/src/sessions/index.mjs).

For non-interactive runs (jobs, automations, channels, heartbeats) — the runs nobody watches — two deterministic checks now run at finalization and append a localized (DE/EN/IT) caveat to the agent's answer. Interactive chat is excluded because the user can see the same state in the UI.

Unfinished plan items

The run tracks the latest agent_todo_update list (the tool replaces the whole list, so the last one wins). If the initial run ends with pending or active plan items, the runtime spends its single bounded continuation on the instruction to finish those items or explicitly remove items that are no longer required. If the authoritative todo list remains open afterward, a caveat is appended:

Note: this task finished, but 1 of 3 plan items are still open. Please check whether the work is actually complete.

This makes the agent todo plan load-bearing — previously it was purely a UI element that nothing checked before a run reported success.

Failed side-effecting actions

The harness already hard-fails an automation when a required Team Chat post errors (requiredAutomationToolFailure, jobs/index.mjs) — but that check covers exactly one tool. detectFailedSideEffects generalizes the idea across the native catalog using an expanded mutation vocabulary (including set/start/stop/cancel/retry, enable/disable, revoke/share, install, execute/run, connect/sync, generate, and shell/package operations), while excluding confidently read-shaped names such as get/list/search/status/preview/inspect. If a run's own tool evidence shows such an action failed, a caveat is appended and the process counter runCompletionUncertified (metrics.mjs) is incremented:

Note: at least one side-effecting action failed during this automated run (emails_send_draft). The task may not have completed as intended — please verify.

This is deliberately keyed on actual tool errors, not heuristic obligation guesses, so it has a very low false-positive rate. It is non-fatal — the existing Team-Chat hard-fail contract is unchanged.

Evidence-bound completion judge

An independent completion judge now runs on the autonomous run path, modeled on the /goal orchestrator judge (orchestrator-sessions/goal-loop.mjs). One judge call (the run's own model, no tools) extracts up to eight obligations in the task's language and decides whether each obligation is satisfied by the tool evidence, on the rule that "the agent saying it finished is not evidence." Every judge verdict is runtime control and diagnostic data: it is recorded in the run event/metric and may drive one bounded continuation, but it is never appended to assistant chat text. This includes incomplete, blocked, and uncertified outcomes. Pieces:

  • buildJudgeEvidenceText(toolOutputs, outboundSends) — a compact, content-bounded evidence summary (stable tool-01 / send-01 ID, tool name, ok/error status, and short preview);
  • buildAutonomousJudgePrompt(...) — multilingual obligation extraction plus per-obligation evidence IDs/status in one structured judge request, enforced through the provider's strict JSON-schema response format;
  • normalizeAutonomousVerdict(...) — a complete | incomplete | blocked | uncertified parser that refuses a complete verdict unless every extracted obligation has cited evidence;
  • buildAutonomousContinuationPrompt(...) — the bounded continuation instruction used only in continue mode.

Gating (cost control). The judge only runs for a non-interactive run that (a) was not steered, (b) executed at least one tool, and (c) produced a textual answer. It is no longer gated by an English keyword regex. A trivial channel reply ("hi") that executes no tools still does not trigger a judge call.

Modes — the autonomous_completion_judge_mode setting (Settings → ClapilotAICore → Runtime):

  • off — no extra model call and no synthetic verdict or caveat; a run.completion_judge_skipped event records the deliberate skip;
  • caveat (default, legacy stored value) — one judge call; record the verdict in runtime diagnostics without changing assistant output;
  • continuecaveat plus one bounded continuation run on an incomplete verdict (injects one executable next_operation plus the unmet deltas), then re-judges once. If the todo gate already used the continuation, the judge cannot start another. The overall run is bounded at two judge calls plus one continuation.

Provider errors, invalid JSON, and unsupported evidence IDs are converted to an internal uncertified verdict; they never alter assistant output and never fail the underlying run. Three consecutive judge failures open a five-minute process-local circuit breaker. A run.completion_judged event records the verdict, obligation count, mode, continuation use, and breaker state.

Silent-delivery boundary

Explicit delivery sentinels are interpreted before server-authored finalization. If the model's final result is NO_MESSAGE, NO_REPLY, or AUTOMATION_SILENT (including heartbeat WATCH/RESOLVE directives), that delivery intent is immutable: fallback notes, activity summaries, restart notices, todo warnings, failed-side-effect caveats, and tool-budget caveats cannot turn the run into a chat post. The runtime still records judge and caveat diagnostics in events and increments the uncertified metric. This prevents an operational diagnostic from becoming user-visible content while preserving the evidence needed to investigate it.

Tests

Colocated with each module:

  • src/tool-result-quarantine.test.mjs
  • src/run-completion-caveats.test.mjs
  • src/autonomous-completion.test.mjs
  • src/providers/index.test.mjs

Web: src/lib/app-settings.test.ts covers the new app_settings columns through the positional INSERT param check.

Run with npx vitest run src/tool-result-quarantine.test.mjs src/run-completion-caveats.test.mjs src/autonomous-completion.test.mjs from services/clapilot-agent.