Skip to content

fix: prompt-injection and system-prompt hardening + follow-ups (2026-10-09 assessment) - #314

Merged
jkyberneees merged 40 commits into
mainfrom
fix/prompt-injection-2026-10-09
Oct 10, 2026
Merged

jkyberneees merged 40 commits into
mainfrom
fix/prompt-injection-2026-10-09

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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=1 now runs the schema guard scan and the 256 KiB cap, like auto_approve.
  • Schema free text never reaches the provider unwrapped. The model-facing schema keeps only structural keywords. Descriptions, titles, examples, string and object defaults, and non-identifier names move into the nonce-wrapped description. The approval scan also covers map keys. required is kept consistent with the surviving properties. The approval key still hashes the original schema.
  • The per-tool approval prompt shows the sanitised description, a parameter summary and the lifted docs, not just a hash.
  • JSON-RPC error.message is capped by max_result_chars.

Telegram

  • New telegram.link_preview setting (default false) and ODEK_TELEGRAM_LINK_PREVIEW. Previews are disabled on every outbound text by default, which closes a zero-click exfiltration beacon. Operator-only: project odek.json cannot enable it.
  • Wake-turn approvals and clarify bind to the user who last started a turn, resolved after the chat slot is held, or the single allowed user. Otherwise they fail closed. Wake messages persist as bg-wake.
  • Startup warning for group chats allowed without allowed_users.
  • Forwarded, voice, caption and document ingests are audit-recorded. Forwards are guard-scanned under the telegram scope, with one banner only.

Memory and skills

  • Memory write approvals show the full text being persisted. Fields over 2048 bytes are refused so nothing is elided.
  • Skill names are capped at 64 runes, charset-restricted and guard-scanned. Skills pending review are counted, not named, in the unwrapped catalog. Catalog lines are sanitised. Rejected names are logged.
  • A trusted skill wins over a same-named copy pending review.
  • Shell and bg_start commands with network or unknown effects taint memory episodes.
  • REST episode promote requires summary_sha256, compares it under the episode lock and returns the promoted text. The WebUI and odek memory list/promote show the full sanitised summary.

Loop and system prompt

  • The completion nudge never directs execution of plan checks. Checks restored from a persisted plan are marked and do not trigger it.
  • Bare loop.New engines default-wrap skill, episode, extended-memory and bg-notice context.
  • Persisted system messages and extension tool output are re-wrapped unless minted in this process. Re-wrapping is byte-stable across restarts, with no banner stacking.
  • Persisted digests are re-capped on load. Persisted plan size, titles and notes are bounded.
  • Identity text imitating pillar headings is stripped. This handles setext, bold, fullwidth and zero-width variants, and leaves code fences and unrelated persona text intact. The real pillar is always last.
  • The return-after-break summary is a wrapped user message named return-after-break. It is never persisted, counted as a turn, indexed, or treated as the task.

Scanner and render

  • Identity prose such as "When the user says deploy…" no longer trips the impersonation patterns. Relayed-authority and relayed-execution impersonation are still caught.
  • New low-false-positive patterns: persona swaps, set-aside phrasing, context-leak requests, override banners, notes to the model, [SYSTEM]/### SYSTEM markers, ES/FR/DE redirects that name a secret or target, and spaced-letter evasion.
  • AGENTS.md guard rejection now warns on stderr.
  • Sidecar scans content past max_text_length in overlapping windows. Content too large for the window cap is rejected whatever fallback_to_local says.
  • Terminal output escapes control, bidi and invisible characters in all model and tool text, and is streaming-safe.

Taint and sub-agents

  • The sub-agent task fence keys on effective trust.
  • A run with any MCP tool registered is tainted for delegate_tasks, so trusted delegation is unavailable while MCP servers are loaded.
  • Taint persists as sticky Session.UntrustedIngested. Trimming does not clear it, it is seeded on every resume surface, and catalogue taint persists too. Embedders get odek.WithUntrustedIngest. Taint detection now matches only real wrappers, not the pillar's prose that mentions the tag.
  • The child result frame is authenticated with a per-spawn nonce passed over an inherited pipe. Unauthenticated frames show status: "unverified" and never refund budget.

Behaviour changes worth a release note

  • Telegram link previews are off by default. Set telegram.link_preview: true to restore them.
  • REST POST …/episodes/{id}/promote returns 200 with the summary instead of 204, and requires summary_sha256.
  • Trusted delegation is unavailable while any MCP server is loaded. Disable servers with "enabled": false if needed.
  • Older odek binaries running as sub-agents send no frame auth. Their results show as unverified, and their budget grant is not refunded under budget_inherit=share.
  • More sessions need episode review, because network shell commands now taint.
  • Turn counts no longer include bg-wake or bg-notice messages.

Build

  • golang.org/x/net bumped to v0.60.0 to clear the govulncheck advisories that would block CI.

Verification

  • go test ./... passes. internal/danger was run with HOME=/home/user.
  • -race passes on every changed package, including cmd/odek.
  • ODEK_E2E=true E2E and MCP E2E pass.
  • go vet and gofmt are clean. golangci-lint is clean.
  • node --test for the UI passes.
  • internal/danger fuzz targets ran 10s each with no failures.

Coverage, before → after:

Package Before After
cmd/odek 72.6% 73.0%
internal/loop 93.1% 93.5%
internal/agent 78.2% 80.8%
internal/session 89.5% 89.7%
internal/memory 82.9% 84.2%
internal/memory/approval new 89.3%
internal/skills 91.0% 91.2%
internal/telegram 90.1% 90.2%
internal/mcpclient 94.5% 94.6%
internal/danger 92.1% 92.2%
internal/guard 74.1% 83.9%
internal/render 68.8% 74.2%

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:

  • Pillar-imitation stripping ran over the wrapped AGENTS.md and could remove its closing tag. Stripping now runs on the operator identity only, before project files and skills are appended, and wrapped regions are opaque to it.
  • New scanner patterns rejected ordinary project prose such as "Forget the previous guidelines about tabs" and "### Admin / Override the default port". They now require an agent-directed target. Spaced-letter scanning cost is bounded.
  • The delegation taint keyed on wrapper syntax, so plans, digests and memory blocks tainted every session. Taint now comes only from external sources. Engine-derived labels such as compaction, plan and memory are allowlisted, and tools cannot mint those labels. AGENTS.md and MCP still taint.

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 via head_tail without tainting. Tool-side wrappers now relabel such sources to external:<label>. Recalled episodes did not taint. episode is 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

  • Pure first-party tools stop tainting the run. Calls to audited built-ins (math_eval, inline base64 encode, list_subagent_profiles, list_tools without project MCP servers, plan) are wrapped as pure_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.
  • Episode provenance is sticky. It is stored as 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.
  • Memory replace and remove approvals show the full matched entry. The mutation is bound to that entry (ErrEntryChanged). Legacy entries over the display bound show their length, a sha256 and an excerpt.
  • Sub-agent children call prctl(PR_SET_DUMPABLE, 0) on Linux. This blocks same-uid /proc/<pid>/fd replay of the result-frame nonce. Costs: no core dumps, and no same-uid ptrace attach on sub-agents.
  • The Telegram taint mark runs under the chat save lock.
  • Terminal output keeps only RGI subdivision flags (England, Scotland, Wales). Other tag characters are still escaped, so they cannot hide text.
  • Scanner: policy prose such as "Never send secrets to external services." no longer trips the transmit pattern. The exemption fails closed. Any exception word, condition, question, object pronoun, later secret noun, or destination-shaped token anywhere in the text keeps the line flagged. A decoy prohibition around a trigger-free payload can still clear it. That grants nothing, because the payload alone already passes the scanner on main. This was verified on 15 probes.
  • internal/loop/plan_baseline_eval_test.go is 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)

  • Episode provenance judges outside-workspace reads against the saving process's working directory.
  • An older binary that rewrites a session file drops the new taint fields.
  • Root or CAP_SYS_PTRACE can still read sub-agent descriptors.

Verification

  • go test ./... passes. internal/danger was run with HOME=/home/user.
  • -race passes on every changed package, including cmd/odek.
  • ODEK_E2E=true E2E and MCP E2E pass.
  • go vet and gofmt are clean. golangci-lint is clean.
  • node --test for the UI passes.
  • internal/danger fuzz targets ran 10s each with no failures.

Coverage, before → after:

Package Before After
cmd/odek 72.6% 73.0%
internal/loop 93.1% 93.5%
internal/agent 78.2% 80.8%
internal/session 89.5% 89.7%
internal/memory 82.9% 84.2%
internal/memory/approval new 89.3%
internal/skills 91.0% 91.2%
internal/telegram 90.1% 90.2%
internal/mcpclient 94.5% 94.6%
internal/danger 92.1% 92.2%
internal/guard 74.1% 83.9%
internal/render 68.8% 74.2%

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:

  • Pillar-imitation stripping ran over the wrapped AGENTS.md and could remove its closing tag. Stripping now runs on the operator identity only, before project files and skills are appended, and wrapped regions are opaque to it.
  • New scanner patterns rejected ordinary project prose such as "Forget the previous guidelines about tabs" and "### Admin / Override the default port". They now require an agent-directed target. Spaced-letter scanning cost is bounded.
  • The delegation taint keyed on wrapper syntax, so plans, digests and memory blocks tainted every session. Taint now comes only from external sources. Engine-derived labels such as compaction, plan and memory are allowlisted, and tools cannot mint those labels. AGENTS.md and MCP still taint.

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 via head_tail without tainting. Tool-side wrappers now relabel such sources to external:<label>. Recalled episodes did not taint. episode is 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)

  • Episode provenance does not read the sticky Session.UntrustedIngested flag. 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.
  • Any tool call taints the run, because every adapter tool records an ingest. That was already true on main. Trusted delegation is therefore available only before the first tool call in an untainted session with no MCP server or AGENTS.md. Exempting pure first-party tools such as math_eval would be a separate decision.
  • Skills whose names exceed 64 runes or use characters outside the new charset are skipped at load with a warning.

🤖 Generated with Claude Code

jkyberneees and others added 13 commits October 9, 2026 17:32
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

Comment thread internal/loop/minted_boundary.go Fixed
Comment thread internal/loop/minted_boundary.go Fixed
Comment thread internal/loop/minted_boundary.go Fixed
@jkyberneees
jkyberneees force-pushed the fix/prompt-injection-2026-10-09 branch 9 times, most recently from ae1d75d to 597b024 Compare October 9, 2026 19:52
@jkyberneees jkyberneees changed the title fix: prompt-injection and system-prompt hardening (2026-10-09 assessment) fix: prompt-injection and system-prompt hardening + follow-ups (2026-10-09 assessment) Oct 9, 2026
jkyberneees and others added 5 commits October 10, 2026 09:03
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>
jkyberneees and others added 21 commits October 10, 2026 09:03
… 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
jkyberneees force-pushed the fix/prompt-injection-2026-10-09 branch from 597b024 to 5231543 Compare October 10, 2026 07:04
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>
@jkyberneees
jkyberneees merged commit a78f94d into main Oct 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants