Skip to content

fix: 81 validated findings from the 2026-10-11 bug hunt - #316

Merged
jkyberneees merged 105 commits into
mainfrom
fix/bughunt-2026-10-11
Oct 11, 2026
Merged

jkyberneees merged 105 commits into
mainfrom
fix/bughunt-2026-10-11

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the 2026-10-11 bug hunt at 3e3ee53: eight area hunters each wrote a failing TestRED_* test per finding, and an independent Haiku agent replayed and adversarially validated every one. 82 findings → 69 confirmed, 12 plausible, 1 rejected (a leading / in Telegram is the documented command convention). All 81 remaining findings are fixed, one commit per finding, each with its RED test kept as the regression test.

Every area then got an adversarial Sonnet review of its fix branch; every real issue those reviews found is fixed in follow-up commits on this branch (listed below).

High severity

  • Danger classifier: git grep -O, git send-email --smtp-server=<prog>/--sendmail-cmd, sqlite3 pipe dot-commands / edit() / .shell on stdin, zip -m / 7z -sdel, and alias (now tracked by use: a later command that names an alias is classified as its expansion) were all under-classified.
  • Telegram: the daily token budget stopped being enforced once one run overshot it.
  • Serve: MCP server processes leaked on every reconnect when sandbox setup failed.
  • Redaction: AWS SecretAccessKey/SessionToken were not redacted.

By area

Area Findings Highlights
danger 13 git credential/gpg secret exports → system_write; sqlite writefile()/VACUUM INTO/write dot-commands are write targets; recursive chmod/compress and mv of $HOME → system_write; git apply --unsafe-paths, format-patch -o
loop 8 tool defs sorted for a stable prompt-cache prefix; batch denial / budget-skipped calls no longer corrupt plan checks; check_replace can no longer write a plan the resume parser drops; quote-aware argv0
memory/session 10 audit log redacted; corrupt index.json rebuilt from session files; symlink-escape episode taint; stale episode index; unsafe merged facts; rerank actually applied; embedder refit race; quarantine listing sanitized
telegram/schedule 10 budget overshoot recorded (and failed/cancelled turns billed); bg exit posted once; MarkdownV2 escaping of \, lone */backtick; commands split on any whitespace; re-enable no longer fires a catch-up run
serve 9 approval expiry clears REST runs; shutdown cancels REST runs and refuses late admissions; serve honours trusted sandbox opt-outs; post-cancel approvals deny immediately; /api/jobs and bg_job redact before truncating
tools 10 config_view accepts real argument JSON; http_request with mutating methods/credential headers → network_upload; patch diff/CRLF; symlinked search roots; ** char classes; json_query chained indices; diff trailing newline; bgproc 143/137 after stop
config/skills/MCP 10 planning.remind merged; secrets.env tab comments; MCP Mcp-Session-Id (dropped on cross-host redirect), notifications/initialized on stdio, ping replies on a separate bounded queue; REPL escape sequences; project skill promote requires --force; unknown trust_level fails closed; file:// refs percent-encoded
ops 11 redaction of Basic auth, JSON Bearer headers, refresh_token, X-Amz-Security-Token, deterministic longest-first known-value replacement (with a bounded Basic-auth pass); PKCS#8 key detection; cleanup dry-run shares the real sweep's rules; audit source match on path boundaries; quarantined audit copies age out

Behaviour changes worth noting

  • odek serve --no-sandbox with ODEK_REQUIRE_SANDBOX=1 now refuses to start (matches run).
  • sqlite3 db < schema.sql and … | sqlite3 db now prompt (stdin content cannot be scanned at classification time).
  • An alias definition followed by a use is classified by the expansion (e.g. alias dc="docker compose"; dc up prompts).
  • Existing tests that pinned buggy behaviour were updated (noted in each commit body): subagent trust fail-open case, a vacuous REPL editor test, TestRecallRerank, three browser URL tests, TestEscapeMarkdown_AllReservedChars, TestWithHTTPClientInjectsTransport.

Review follow-ups (adversarial Sonnet review per area)

  • loop, tools: no issues.
  • serve: shutdown admission race closed (late runs refused with 503).
  • ops: Basic-auth pass bounded back to main's throughput; X-Amz-Security-Token added.
  • config: short-keyword plural matching restored; session id stripped on cross-host redirect; ping flood can't starve requests.
  • telegram: lone-backtick regression, overshoot warning delivery, billing of failed runs.
  • memory: unsafe merge stores the new fact separately instead of dropping it.
  • danger: two review rounds — alias tracking redesigned from definition-time to use-time, gpg --export-secret-ssh-key, sqlite literal-only write paths and SQL comment/newline normalisation, piped sqlite write dot-commands, then time prefixes, nested-guard scoping, .o/.trace, writefile newline and quoted schema names.

Coverage (before → after, per touched package)

Package Before After
cmd/odek 73.1% 74.0%+ (each area raised it)
internal/danger 92.20% 92.39%
internal/loop 93.6% 93.8%
internal/redact 92.7% 94.8%
internal/mcpclient 94.6% 95.9%
internal/telegram 90.2% 91.0%
internal/schedule 97.0% 97.2%
internal/guard 83.9% 84.3%
internal/maintenance 95.7% 95.9%
internal/runtimelog 88.7% 89.0%
internal/config 85.9% 86.1%
internal/memory 84.9% 85.0%
internal/memory/extended 90.8% 90.9%
internal/session 90.3% 90.4%
internal/artifact 91.0% 91.1%
internal/skills 91.2% 91.2%
internal/bgproc 88.5% 88.5%

Test plan

  • go build ./..., go vet ./..., gofmt -l clean
  • go test -count=1 on every package; internal/danger with HOME=/home/user
  • go test -race on every changed package
  • danger fuzz targets (20s each) per fix round
  • ODEK_E2E=true … -run TestMCPClientE2E_
  • CI (test, lint, vuln, ui-js, CodeQL)
  • Opus full-branch adversarial review — one low issue (audit source match after the cross-area merge collapsed distinct token URLs via redaction); fixed in two commits with regression tests, each re-reviewed (second re-review: no issues)

🤖 Generated with Claude Code

jkyberneees and others added 30 commits October 11, 2026 11:27
A refused batch card marked every call denied, so an auto-allowed check that
was never shown became blocked and stopped gating completion. Only listed
calls are now denied; unlisted siblings are recorded as not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Budget-skipped and cancelled-before-execution calls were recorded as failed
runs, invalidating passed checks and marking skipped checks failed. They are
now flagged not-run and skip check recording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildToolDefs ranged over the registry map, so each run sent tools in a new
order and missed the provider prompt cache. Definitions are now name-sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me cap

Appending an evidence note could push a check description past the 200-rune
cap the resume parser enforces, dropping the whole plan on continue. The
combined description is now clamped to that cap at write time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The replacement was validated in isolation, so it could reuse a sibling
check's id and the resume parser then dropped the plan. Such replacements
are now rejected with a retryable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The revision reason built from an unbounded justification could exceed the
240-rune cap the resume parser enforces, dropping the plan on continue. The
justification is now clamped so the reason always fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck

A fresh replacement check left its step done with no evidence, unlike every
other evidence-resetting path. The step now reopens to in_progress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never leak

argv0 used strings.Fields, so a quoted env assignment value with spaces leaked
its second word as the program name into tool_call_started events. Words are
now split honouring quotes and escapes; an unterminated quote yields nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generic matcher missed SecretAccessKey/SessionToken (STS JSON) and the
YAML colon form AWS_SECRET_ACCESS_KEY: value; it now lists both key names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the Bearer header form was covered, so base64(user:password) from curl -v
and HTTP dumps persisted verbatim. A Basic token is now redacted when it decodes
to a printable user:password pair; the perf-test reference includes the new pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Bearer pattern required a colon right after Authorization, so the quoted
header-object form used in fetch()/axios code and HTTP tool arguments leaked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generic key list covered access_token but not refresh_token, so opaque
refresh tokens from OAuth token responses were persisted verbatim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The known-value replacer was built in map order and strings.Replacer takes the
first matching argument, so a registered prefix could leave a longer secret's
tail in clear. Forms are now sorted longest first, deterministically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local credential check required an algorithm label, so unlabelled
BEGIN PRIVATE KEY and BEGIN ENCRYPTED PRIVATE KEY blocks scanned BENIGN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dry run filtered sessions on UpdatedAt only while Store.Cleanup skips pinned
ones; both now share session.CleanupExpired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dry run clamped logging.max_age_hours at 36500 while the sweep clamps at
87600; the preview now calls maintenance.RuntimeLogExpiredPreview, which shares
the policy, clamp and cutoff with PruneAtStartup. Adds the cleanup RED tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sweepAudit only removed names ending in .json, so <id>.json.corrupt-<ts> copies
(same user messages) outlived audit_max_age_days. Sweep and dry run now share
one audit selection that includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isSource used a raw string prefix, so a look-alike host extending the fetched
host name was dropped from untrusted_resources; matches now require equality or
a '/', '?' or '#' boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its 99-byte label exceeded the runtime log's 96-byte metadata cap and was
dropped; it is now telegram_allow_all_no_allowlist, and a test checks every
mapped surface label survives the runtime log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pass prefiltered on "basic" alone and ran submatch extraction over the
whole text with an uncapped token, making RedactSecrets 2-3x slower on large
input. It now needs "authorization" too, caps the token at 1000 bytes
(longer runs are redacted whole) and takes submatches per match only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AWS session token in the X-Amz-Security-Token header and presigned-URL
query parameter matched no pattern; security_token joins the generic key list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approver's approval_expired frame was ignored by the run sink, leaving a
dead pending entry and status stuck at waiting_approval. Treat it like an ack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trailing-hour filter reused the slice in place but only stored it back
when over the limit, duplicating a live timestamp. Always store the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
newServeAgent returned on sandbox failure without stopping the MCP servers it
had started, and skipped the guard on odek.New failure. One fail path now
releases everything acquired so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gistry

subagentRunKeysForSession read serveRun.SessionID holding only the registry
lock while record() writes it under run.mu. Read each run under its own lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shutdown closed WebSockets but never cancelled REST runs, so a run blocked on
an approval outlived the drain and skipped its sandbox/MCP cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The WebUI session title was cut to 60 bytes before save-time redaction, so a
key straddling the cut survived as a fragment. Redact first, as REST runs do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
serve forced the sandbox on unless --no-sandbox was passed, ignoring
"sandbox": false in trusted config and ODEK_NO_SANDBOX=1. It now uses the
same intent rules as run, including ODEK_REQUIRE_SANDBOX=1 rejecting opt-outs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cancel re-arms the approver, so a prompt from the draining loop after a run
was cancelled waited out the approval timeout unseen. REST runs now Close the
approver, which denies every later prompt immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The REST job listing returned the raw command head while the bg_job frame
redacted it, and both clamped before redacting. One helper now redacts the
whole command, then clamps, for every client surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jkyberneees and others added 22 commits October 11, 2026 11:52
The one-token rule used the raw word count, so a query with one usable token
plus a short or repeated word never qualified a session by message content.
It now uses the count of distinct usable tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the corrupt-index rebuild: a directory planted at index.json
served the cached index, so a lazy save skipped its write and the failure
went unnoticed. A non-regular index path now reads as empty again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…safe

An unsafe merge-on-write combination made AddFact fail, dropping a fact that
passed the per-fact checks. The merge is now skipped, the existing entry is
left untouched and the new fact is added as its own entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cution

git grep ran its pager option through the shell but only the repository
arming check was consulted, so the command classified safe. The pager option
is now detected in every spelling (fused, clustered, abbreviated long form).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
send-email was unknown to every git adapter, so --sendmail-cmd, the *-cmd
options and a path-valued --smtp-server ran programs unprompted, and mailing
local files was safe. They are now code_execution, and send-email/imap-send
carry network_egress plus network_upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
git credential fill and the credential-<helper> verbs print stored plaintext
credentials but classified safe, while cat ~/.git-credentials is system_write.
They now classify system_write like the direct file read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gpg sits on the read-only safe list, so --export-secret-keys and
--export-secret-subkeys printed private key material unprompted. Both (and
their abbreviations) now classify system_write like reading the key files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecution

Only literal .shell/.system/.read/.load were detected, so '|cmd' arguments of
.import/.once/.output, abbreviated .sh/.sy and the edit() SQL function ran
programs while classified safe or local_write. A dot-command parser now flags
all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sqlite3 executes dot-commands from stdin, but piped or redirected scripts were
never inspected, so echo '.shell …' | sqlite3 and sqlite3 db < cmds.sql were
safe. Redirected and non-literal piped input is now code_execution and static
pipes are scanned for program launchers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sqlite3 write-target adapter only read .output/.once/.save/.backup, so
writefile('/etc/…') and VACUUM INTO wrote anywhere while classified safe. Their
literal paths are now write targets (non-literal paths fail closed), also in
static scripts piped to sqlite3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zip -m/--move and 7z -sdel delete every archived source, like tar
--remove-files, but classified local_write and were auto-allowed. They are now
destructive in every spelling (clusters, long-option prefixes, any case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mv operands were checked only for sensitive paths, and the home directory
itself is not one, so mv ~ /tmp/x was auto-allowed while mv -t /tmp/x ~ was
system_write. An mv source naming a home directory or its ancestor now
escalates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rectory

Only the literal operand was path-checked, so chmod -R 777 ~ and gzip -r ~
were local_write although they rewrite ~/.ssh and the rc files. chmod, chown,
chgrp, chattr, setfacl and the in-place compressors now escalate to
system_write when an operand names a home directory or its ancestor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alias was a safe builtin and its body was never analysed, yet sh expands
aliases on later lines, so alias ls='rm -rf ~' followed by ls ran unprompted.
Each NAME=BODY definition is now analysed as its body, including the denylist
scan. The curl|sh case of the original test is pinned at code_execution, the
class that pipeline has on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only --output was a write target for format-patch, so -o and
--output-directory wrote patch files into any directory while classified
safe. Both spellings (fused, clustered, abbreviated) are now write targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… repo

git apply had no write-target adapter, so --unsafe-paths --directory=/etc
applied patches anywhere while classified safe. --unsafe-paths now floors the
command at system_write and its --directory is judged by the path rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins option terminators, negations and next-word values for the mv, zip, 7z,
sqlite3, send-email and git apply adapters, and summarises the new rules in
AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sion

Classifying an alias body at its definition missed a benign body completed by
its arguments (alias ls=rm; ls -rf ~) and over-escalated unused aliases. The
analysis now keeps an alias table across segments and nested payloads and
classifies each use as body plus remaining words, alongside the literal
command; escaped-space values read like quoted ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only --export-secret-keys and --export-secret-subkeys were covered, so the SSH
format export of the same private key stayed safe. Every --export-secret-*
option now classifies system_write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the leading literal of the path argument was read, so a concatenation
('/tmp/'||'../etc/x') was judged by its harmless prefix, and a newline or
comment between VACUUM and INTO hid the target. Arguments must now be exactly
one literal (else unknown), and comments and line breaks are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write dot-commands were read only as exact .output/.once/.save/.backup
arguments with the first operand unquoted, so .log/.clone/.open, abbreviations,
quoted or second operands and static scripts piped into sqlite3 escaped the
path rules. One parser now feeds every operand to the write-target check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 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 ee63ac2 Commit Preview URL

Branch Preview URL
Oct 11 2026, 10:55 AM

jkyberneees and others added 6 commits October 11, 2026 12:22
… guard

An alias used after time [-p] was not expanded, and the guard that stops an
alias from re-expanding its own name leaked into eval and sh -c payloads
parsed afresh inside the body. The command word now skips time/! prefixes and
the guard covers only the expansion text itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d .archive

sqlite3 resolves one-letter abbreviations such as .o (output), and .trace and
.archive write files, but the parser required two letters and lacked those
commands, so .o /etc/x and .trace /etc/x were safe. Every prefix and both
commands are now write targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d quoted schemas

A line break before writefile's parenthesis or a quoted schema name between
VACUUM and INTO kept the target unread, so both were safe. Line breaks are
accepted, every SQL quoting style is read as a schema name, and a writefile or
VACUUM ... INTO that still cannot be read fails closed as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Satisfies staticcheck QF1001; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Redaction swallows the rest of a URL after a token, so an injected URL on
the fetched page's host could collapse into the source's redacted form and
drop out of untrusted_resources. Compare this turn's raw strings; a
persisted source that lost text to redaction excludes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The raw-string source match left redacted resources from persisted ingest
records unable to match any source, so a fetched token-bearing URL naming
itself was flagged. Raw resources match raw sources; persisted resources
match the redacted sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jkyberneees
jkyberneees merged commit ce0bbfe into main Oct 11, 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.

1 participant