Skip to content

fix: module-by-module bug sweep - security, correctness, and reliability fixes - #168

Merged
jkyberneees merged 15 commits into
mainfrom
fix/module-bug-sweep
Aug 31, 2026
Merged

fix: module-by-module bug sweep - security, correctness, and reliability fixes#168
jkyberneees merged 15 commits into
mainfrom
fix/module-bug-sweep

Conversation

@jkyberneees

Copy link
Copy Markdown
Contributor

Module-by-module expert bug sweep (2026-08-31). Every fix landed RED-first with regression tests; full suite 31/31 green, vet + golangci-lint clean, -race clean on all changed packages.

Security

  • artifact_read TOCTOU: reads re-opened the artifact by path after validation, following symlinks, with no sha256 re-check - a same-user process could swap the file for a symlink and stream arbitrary readable files into the agent context. Reads now Lstat + O_NOFOLLOW + verify the digest of every byte served.
  • skill import SSRF: isPrivateHost was checked only on redirects; the initial fetch could hit link-local metadata addresses. Now refused up front.
  • Imported skills never pinned NeedsReview: URI-imported skills were trigger-matchable immediately with attacker-derived triggers. Imports now pin after parsing so remote frontmatter cannot clear it.
  • skill_load served NeedsReview bodies on demand, defeating the promotion gate; now refuses with a promote pointer (docs synced).
  • danger classifier: trap payloads and bun -e classified Safe (code exec auto-allowed); env equals-form flags hid a flag-only dump; isRawBlocked denied innocent brace substrings even in godmode; case-fold gap in the home-sensitive path guard; TrustedClasses write raced parallel readers; unread-exec gate bypassed via $VAR operands and no-shebang ENOEXEC fallback; fingerprintFile stat/read swap window.

Correctness

  • budget: cache tokens never counted toward max_input_tokens/max_cost_usd (enforcement now sums exclusive input + cache volumes, usage normalized per provider); share-mode treated an exhausted parent budget as unlimited for children (explicit exhausted flags + fail-fast spawn gates, exit 4).
  • loop: trimContext could drop the original task when a leading injection shifted the protected-head boundary; failure recovery sniffed output text for the literal error-key substring, firing false recovery on successful reads; the compaction digest could be dropped by the next trim.
  • tools: glob newest-first contract broken by walk-order truncation; isBinary rejected Cyrillic/CJK text; shell exit status silently dropped on failing commands with output (false test-pass signal); tree disclosed ~/.odek names the search tools skip.
  • config: secrets.env parser ignored export prefixes, quotes and inline comments, and silently dropped every secret after an overlong line.
  • serve: REST-run goroutines untracked at shutdown (cleanup defers never ran); WS slot leak on failed upgrades wedged /ws; pong raced the live model field; XFF rate-limit keying used the client-controlled first entry with trusted proxies.
  • artifact/mcpclient: max_result_chars bypassed via unbounded envelope fields; metadata lines forgeable from result text; Validate hashed via unbounded read past the size cap.
  • Politeness: friction warning now prints the real approval count; invalid ODEK_* numeric env values warn instead of silently defaulting.

…al fork-bomb blocking

trap was in safeCommands so trap-with-payload auto-allowed code execution; bun was missing from codeEvalPrefixes so bun -e fell through to the Safe install fallback; isRawBlocked denied any command containing both brace-colon substrings, blocking innocent echo patterns even in godmode. Trap query forms stay safe. Found in the 2026-08-31 expert bug sweep; RED-first tests in classifier_bugfix_test.go.
Cache reads and writes are real prompt tokens with real cost, but CheckUsage never saw them: on Anthropic a fully cached prompt counted as zero input, so max_input_tokens and max_cost_usd never tripped. llm now normalizes usage to exclusive semantics on every provider (OpenAI cached_tokens and DeepSeek hit/miss are subsets of prompt_tokens; Anthropic was exclusive already), budget gains Checker.CheckUsageWithCache summing input plus cache volumes, and both loop call sites switch to it. TotalInputTokens is now uncached-only for display. Found in the 2026-08-31 expert bug sweep; RED-first tests in budget_cache_test.go and usage_cache_test.go.
… import fetch

ImportSkill set only Quality, AutoLoad and LastUsed, so a URI-imported skill was trigger-matchable immediately with triggers derived from the attacker-controlled body; imports now pin Provenance.NeedsReview after parsing so remote frontmatter cannot clear it, keeping odek skill promote as the review gate. fetchHTTP also checked isPrivateHost only on redirects, leaving the initial fetch an open SSRF to link-local metadata addresses; private and internal hosts are now refused up front, with fetchHTTPAllow as the explicit opt-out for callers that own the target choice. Found in the 2026-08-31 expert bug sweep; RED-first tests in importer_review_test.go.
…ted the trim boundary

noteLeadingInjection sets ctxLeadDroppableFrom inside the leading system run, and headLen then stops before the first user message, so pass 2 of trimContext dropped the original task as the first standalone group, violating the documented protected-head invariant. Pass 2 now locates the task, keeps it protected while older groups drop, and ends prefix dropping when the scan reaches it; injected blocks remain droppable ahead of the task, which stays the point of the boundary. Found in the 2026-08-31 expert bug sweep; RED-first tests in trim_task_test.go.
A failing command with captured output returned the output with a nil error, silently dropping the exit status, so a failing test or build run was indistinguishable from a passing one (parallel_shell already reports exit_code per command). Output is still returned since the model needs stdout and stderr, but the failure is now named as [command failed: exit status N]; silent failures keep the error return. Found in the 2026-08-31 expert bug sweep; RED-first tests in shell_exit_status_test.go.
…at is actually served

artifact_read re-opened the registered artifact by path at read time with only an os.Stat that follows symlinks, and never re-checked the recorded sha256, so a same-user process could swap the artifact file for a symlink after collation and have arbitrary readable files streamed into the parent context. Reads now Lstat the final component, open with O_NOFOLLOW, stat the open handle (regular, within cap, size matching the ref), and stream sha256 over every byte served, failing closed on any mismatch. renderArtifacts inline previews go through the same gate.
…es write under mutex; case-folded home guard; honest friction count

env --unset=X and other equals-form long options hid a flag-only environment dump behind the wrapper-stripping layer and degraded to Safe; unwrapped TrustedClasses assignment raced parallel readers flagged by -race; shellPathIsHomeSensitive skipped the case-folding its peers apply, letting uppercase path variants slip past on case-insensitive filesystems; the friction warning printed the threshold constant instead of the real same-class approval count.
…ction, tree disclosure, honest batch_patch wording

glob and search_files files-mode truncated to limit in lexical walk order before the mtime sort, hiding the newest files behind the first N lexically-named matches; collection now walks to a 20000-entry cap, sorts by mtime descending, then truncates. isBinary counted every byte over 0x7F as non-printable and rejected Cyrillic and CJK prose as binary; detection now uses NUL, control-char ratio and UTF-8 validity on an 8KB sample. tree skipped no paths, disclosing ~/.odek names the search tools deliberately skip; it now applies the same classification skip. batch_patch description no longer claims atomic all-or-nothing semantics it does not have.
…ims after leading injection

Failure classification sniffed output text for the literal error-key substring, counting successful read or grep results that legitimately contain it as failures and firing false keep-failing hints and tool_recovery signals after three of them; classification now uses the errored flag recorded at execution (call errors, panics, missing tools, denied batches). refreshDigest inserted the compaction digest at headLen without shifting the droppable boundary, so the next trim dropped the fresh digest while the trim warning kept advertising it; insertion now shifts the boundary past itself like the plan and memory slots.
…nfig race, XFF keying

REST run goroutines were untracked so a blocking run outlived listener shutdown with cleanup defers never running - both WS-handler and run goroutines now drain under a bounded wait. The WS connection slot was acquired in the library handshake callback but leaked when the upgrade failed after acquisition, wedging /ws after repeated failed upgrades - a wrapper now releases exactly when the handshake acquired but the handler never runs. The reader-goroutine pong read the live model field while the processor wrote it - hello and pong use an immutable server snapshot taken before the reader starts. With trusted proxies configured the rate-limit key was the client-controlled first XFF entry - now the right-most entry, and empty keys skip limiting instead of inserting a never-evicted shared bucket.
… validation

The NeedsReview promotion gate only blocked trigger matching - skill_load happily served tainted skill bodies on demand, defeating the gate; the load tool now refuses pinned skills with a promote pointer, and descriptions document the withholding. ValidateSkillName rejected neither control characters nor irregular whitespace, and MarshalSkill wrote the name unquoted so YAML-significant names could inject frontmatter on reload; validation now rejects control chars, irregular whitespace and leading YAML-special characters. Docs synced where the skill_load contract is stated.
… share-mode handoff

Snapshot emitted Remaining=0 both for an unconfigured limit and for an exhausted one, and the sub-agent clamp applied only positive values, so a parent whose runtime or cost budget expired mid-batch spawned children with no cap at all. Snapshot now carries explicit exhausted flags, clampLimits clamps exhausted dimensions to zero, and both the parent-side spawn gate and the child-side fail-fast reject with a typed budget error mapped to exit code 4; unconfigured limits remain unlimited and the wire fields are optional so old peers keep their behavior.
…, block metadata forgery, bound hash reads

Render truncated nothing and the envelope branch capped only the text field, so unbounded id/mediaType/summary fields could land in model context at dozens of times the configured max_result_chars - rendering is now capped as a whole with the structured truncation notice and fields are defensively bounded. A tool-result text line beginning with the metadata bullet fabricated extra artifact entries and inflated artifact_count telemetry; continuation lines are now visually distinguished so they cannot parse as metadata. Validate hashed via unbounded io.Copy after the stat, letting a growth race defeat the 64MiB ceiling - the hash read is now limit-bounded and rejects on excess.
…silent truncation

The highest-priority config layer stored export-prefixed keys as literal names, kept surrounding quotes and whitespace-preceded inline comments in values, and never checked scanner.Err, so a single overlong line silently dropped every secret after it and looked like a working configuration with keys missing. The parser now strips the export prefix, exactly one pair of surrounding quotes, and whitespace-preceded comments on bare values (quoted values keep embedded hashes), raises the token buffer to 1MiB, and surfaces scanner errors as warnings.
…cation, open-first fingerprint, env warnings

Dollar-prefixed script operands bypassed the unread-exec gate because the skip treated them as variable refs - expansion and stat now decide, freshness is checked against the expanded path that the read ledger actually records, and interpreter-stage operands gate even without a shebang (ENOEXEC fallback). The shell 1MiB cap, the batch_patch preview and the untrusted-content scan window cut multibyte characters mid-rune - all trim back to rune boundaries now (the batch_patch preview helper is truncatePreviewLine, distinct from the patch diff first-line helper). fingerprintFile stat-then-reopen left a swap window licensing content never seen - it opens first and stats the handle, failing closed on unreadable files. ODEK_* env vars with invalid numeric values silently fell back to defaults - each ignored value now warns on stderr with the variable name, and list parsers name the full original value.
@cloudflare-workers-and-pages

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 00c2ecb Commit Preview URL

Branch Preview URL
Aug 31 2026, 08:34 PM

@jkyberneees
jkyberneees merged commit e01f95f into main Aug 31, 2026
10 checks passed
@jkyberneees
jkyberneees deleted the fix/module-bug-sweep branch August 31, 2026 20:39
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