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:
- tool results are untrusted data and must be scanned before re-entering the model context;
- a run that exhausts its tool budget should wrap up in text, not fail;
- 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.
| Setting | app_settings column | Controls | Default | Values |
|---|---|---|---|---|
| Tool-result quarantine | tool_result_quarantine_enabled | §1 injection quarantine | on | on / off |
| Tool-budget wrap-up | tool_budget_wrapup_enabled | §2 graceful wrap-up (all 6 loops) | on | on / off |
| Run-completion caveats | run_completion_caveats_enabled | §3 todo + side-effect caveats | on | on / off |
| Completion judge | autonomous_completion_judge_mode | §3 LLM completion judge | caveat | off / 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_executecalls 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_recallhint) are appended after the scan, and system-authored results (recall, invalid-argument) are exempt. - A quarantine event surfaces on the tool-end event as
quarantinedSegmentsand 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 (stabletool-01/send-01ID, 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(...)— acomplete | incomplete | blocked | uncertifiedparser that refuses acompleteverdict unless every extracted obligation has cited evidence;buildAutonomousContinuationPrompt(...)— the bounded continuation instruction used only incontinuemode.
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; arun.completion_judge_skippedevent records the deliberate skip;caveat(default, legacy stored value) — one judge call; record the verdict in runtime diagnostics without changing assistant output;continue—caveatplus one bounded continuation run on anincompleteverdict (injects one executablenext_operationplus theunmetdeltas), 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.mjssrc/run-completion-caveats.test.mjssrc/autonomous-completion.test.mjssrc/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.
