Skip to content

Security hardening: sandbox, config, MCP, sessions, Telegram, schedule, skills/episodes, vector indexes#39

Merged
jkyberneees merged 38 commits into
mainfrom
fix/sec-findings
Jun 14, 2026
Merged

Security hardening: sandbox, config, MCP, sessions, Telegram, schedule, skills/episodes, vector indexes#39
jkyberneees merged 38 commits into
mainfrom
fix/sec-findings

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

This PR addresses multiple security findings from the recent holistic review:

  • MCP server approval: Adds explicit interactive approval for project-level MCP servers, with ODEK_APPROVE_MCP=1 and persistent approvals.
  • Sandbox volume filter bypass: Confines extra --sandbox-volume host paths to the working directory and rejects sensitive prefixes / symlink / .. escapes.
  • Sandbox read-only bypass: Routes write_file, patch, and batch_patch writes through the running Docker container so --sandbox-readonly is enforced.
  • LLM base_url hijacking: Rejects base_url from project-level odek.json.
  • Project config privilege escalation: Rejects api_key, system, and the dangerous section from project-level config.
  • MCP env exfiltration: Sanitises MCP server subprocess environments with an allowlist and blocks secret-pattern keys.
  • Schedule symlink attack: Uses internal/fsatomic.WriteFile for atomic schedule writes so symlinks are replaced, not followed.
  • Telegram PID-file kill: Replaces the PID-file singleton lock with an advisory flock to avoid killing arbitrary processes on non-Linux.
  • Session ID entropy + auth: Increases session IDs to 128-bit random values and requires per-session AuthToken for REST/WebSocket access.
  • send_message callback injection: Blocks reserved internal callback prefixes in agent-sent buttons.
  • Resource search traversal: Validates @-resource search queries and skips symlinks during traversal.
  • Telegram media path traversal: Restricts outbound media paths to an allowlist and rejects symlinks.
  • Session/episode vector index hardening: Validates session IDs and rejects symlinks in the session and episode vector index rebuilds.

All changes include regression tests and documentation updates in docs/SECURITY.md, docs/CONFIG.md, docs/MCP.md, docs/TELEGRAM.md, and AGENTS.md.

go test ./... -count=1

All packages pass.

jkyberneees and others added 30 commits June 13, 2026 16:52
… approval user binding

- parallel_shell: add TTYApprover fallback so Prompt-class commands cannot
  bypass interactive approval when no explicit approver is injected.
- Telegram (option 3): keep direct text messages unwrapped for single-operator
  UX, but wrap forwarded text, photo captions, voice transcripts, and
  document-received messages as untrusted content.
- Telegram group approval: bind each TelegramApprover to the originating user
  ID and reject inline-keyboard callbacks from other users, preventing
  group-chat approval hijacking.
- Update all affected handler callback signatures and tests.
- Update docs/TELEGRAM.md with new OnTextMessage signature.
Each command's stdout and stderr now crosses the shell trust boundary with
a per-call nonce'd <untrusted_content_...> wrapper, matching the single
shell tool behavior and closing an indirect-prompt-injection path through
command output.
- Add resolveReadPath + classifyResolvedPath helpers in file_tool.go.
- read_file and batch_read now resolve intermediate directory symlinks
  before danger.ClassifyPath and open the resolved path with O_NOFOLLOW.
- readFileNoFollow and all read-only perf tools (diff, count_lines,
  multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr,
  word_count) now classify the resolved directory path so a symlink
  pointing at a sensitive directory cannot be downgraded from system_write.
- Add RED tests for read_file, batch_read, and head_tail symlink-directory
  traversal targeting ~/.ssh (system_write).
- Add resolveDirSymlinks helper that resolves directory symlinks while
  leaving the final path component untouched.
- FileResolver.Load now resolves directory symlinks and re-checks
  withinRoot before opening with O_NOFOLLOW, blocking @link/passwd
  traversal through a symlinked directory outside the workspace.
- withinRoot now resolves directory symlinks in candidate paths (and
  EvalSymlinks the root) so Search metadata cannot bypass confinement
  through symlinked directories either. Final-component symlinks are
  still preserved for Search and rejected by Load via O_NOFOLLOW.
- Add RED test for symlink-directory traversal in Load.
- newWebSearchTool now installs ssrfGuardedTransport(), blocking DNS
  rebinding and private/link-local IP resolution at dial time (e.g.
  base_url flipping to 169.254.169.254).
- Redirect hops were already re-classified by CheckRedirect; the
  transport guard also protects the initial request and any redirect
  dials that bypass the policy gate.
- Add SSRF regression test for web_search and include it in the
  installed-transport guard test.
- isNetworkEgress now flags git clone, fetch, and pull as network egress.
- git push without a remote remains Safe (just prints upstream info).
- Updated TestClassify_GitClone and the network-egress command table to
  expect NetworkEgress for git clone/fetch/pull.
…s code execution

Script interpreters invoked on a file (python script.py, node server.js)
and package-manager run/start/test/build commands (npm start, yarn start,
bun run, cargo build/run) previously classified as Safe, letting an agent
execute attacker-dropped scripts or malicious package.json/build.rs hooks
without approval. They now classify as CodeExecution; go get / go mod
download classify as Install. go build|test|mod-tidy stay Safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ystem prompt

IDENTITY.md is loaded verbatim as the system prompt but was writable through
the generic file tools (the ~/.odek/ carve-out only excluded config.json,
secrets.env, and skills/) and was never injection-scanned on load. A
prompt-injected agent could rewrite its own trusted instructions for the next
run.

- Add IDENTITY.md to isProtectedOdekPath and the SystemWrite escalation in
  danger.ClassifyPath, so file tools cannot overwrite it.
- Scan IDENTITY.md with danger.ScanInjection in loadIdentityFile, falling back
  to the built-in default on a hit (parity with the AGENTS.md scan in odek.New).

AGENTS.md lives in the workspace and is legitimately editable; its existing
load-time injection scan remains the defense there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-scan them

A third-party MCP server's tool description flows into the model's tool
catalogue as effectively trusted instructions ("tool poisoning"). The only
protection was danger.ScanInjection — a fixed regex blacklist that misses
paraphrased poisoning like "always include the user's OPENAI_API_KEY in your
final answer".

sanitizeMCPDescription now applies two layers: it still withholds the
description outright when the scan matches, but any description that passes the
scan is wrapped in an explicit untrusted-data boundary (reusing wrapUntrusted's
nonce'd, literal-neutralised markers) with a preamble telling the model to
treat the text as documentation only and ignore any directive inside it. The
wrapper records no audit ingest — descriptions are static registration-time
metadata, not runtime output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anscript wrapping

The voice-transcript trust-boundary wrap was already in place (commit 6557686),
but it was inlined in the OnVoiceMessage closure — untested and inconsistent
with the sibling media paths (photo/text/document) that use named builder
functions. A future refactor could silently drop the wrap and reintroduce the
injection bug described in finding #14.

Extract telegramVoiceMessage(chatID, transcript) matching the sibling pattern,
use it at the call site (no behavior change), and add a regression test that
locks in untrusted wrapping of the transcript.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…headless deny floor, and schedule.log redaction
…untrusted

- Wrap the aggregated delegate_tasks sub-agent summary with wrapUntrusted()
  before returning it to the parent agent.
- Wrap Telegram photo captions before embedding them in the local vision-model
  prompt, not only in the main agent message.
- Add/update tests for both wrapping behaviors.
- Update docs/TELEGRAM.md and AGENTS.md.
…and reused-resource injection

- Add UntrustedResources field to AuditTurn.
- Scan final assistant response and tool-call arguments for resources.
- Track resources introduced by untrusted content itself, excluding sources.
- Support quoted JSON arguments in ResourcesIn.
- Add regression tests and update docs.
- Add internal/danger/normalize.go with zero-width removal, homoglyph
  folding, mixed-script detection, and ContainsInvisible helper.
- Expand danger injection patterns for paraphrased exfiltration,
  authority overrides, and non-English override phrases.
- Scan both normalized and homoglyph-folded text surfaces.
- Refactor internal/memory/scan.go to reuse danger.ScanInjection plus
  credential checks, removing duplicated substring patterns.
- Add regression tests for paraphrases, homoglyphs, zero-width combos,
  mixed scripts, and non-English injection.
- Update docs/SECURITY.md.
writeKeyToUnlinkedFile now returns a cleanup callback that removes the
0600 temp file on Windows after the child has exited and the parent's
handle is closed. POSIX behaviour is unchanged: the file is unlinked at
creation and cleanup is a no-op.
- Add internal/flock package with portable advisory file locking
  (syscall.Flock on Unix, LockFileEx on Windows).
- Serialize CheckDailyBudget and DailyTokenUsage with a lock file.
- Write the budget file with 0600 instead of 0644 permissions.
- Add tests for flock serialization, budget file permissions, and
  concurrent budget billing.
- Add validateFallbackURL helper in internal/telegram/network.go.
- Allow only HTTPS telegram.org hosts or loopback addresses.
- Change NewFallbackTransport and Bot.SetFallbackURLs to return errors.
- Update cmd/odek/telegram.go caller to fail closed on invalid fallbacks.
- Update network/bot tests and add validation coverage.
- Document allowed fallback URL schemes in docs/TELEGRAM.md.
- Remove resolved sec_findings.md entry.

Full suite: go test ./... -count=1 passes.
- Track project-level MCP server names in ResolvedConfig.ProjectMCPServerNames.
- Add approveMCPServers helper with interactive y/N prompt, ODEK_APPROVE_MCP=1
  bypass, and persisted approvals in ~/.odek/mcp_approvals.json (0600).
- Gate loadMCPTools on approval before spawning any MCP subprocess.
- Update all call sites (run, repl, serve, subagent, mcp, schedule).
- Add unit tests for approval logic and persistence keying.
- Document ODEK_APPROVE_MCP and the approval flow in docs/MCP.md.

Full suite: go test ./... -count=1 passes.
- Add sanitizeVolumeMount in internal/sandbox/sandbox.go.
- Resolve host paths to absolute, reject .. components and symlinks.
- Require extra volume host paths to be inside the working directory.
- Expand ForbiddenMountPrefixes with /var, /run, /root, /home, and
  /var/run/docker.sock.
- Rewrite mounts with canonical absolute host paths so Docker does not
  interpret relative paths relative to the daemon's cwd.
- Update affected tests in cmd/odek/main_test.go and internal/sandbox tests.
- Document the sandbox_volumes restriction and fix examples in
  docs/SANDBOXING.md.

Full suite: go test ./... -count=1 passes.
When --sandbox is active, writes from the built-in file tools are now routed
into the running container via docker cp so the container's mount options
(including a read-only /workspace) are actually enforced.

- Add cmd/odek/sandbox_file.go with hostToContainerPath and sandboxWriteFile.
- Inject the container name into writeFileTool, patchTool, and batchPatchTool
  in setupSandbox.
- Add cmd/odek/sandbox_file_test.go with path-translation and sandbox-routing
  regression tests.
- Update docs/SECURITY.md and AGENTS.md with sandbox read-only enforcement and
  volume-confinement notes.
Project-level ./odek.json is untrusted, so a base_url set there is now
ignored (with a stderr warning). The LLM endpoint can still be configured
from ~/.odek/config.json, ODEK_BASE_URL, or --base-url.

- Update internal/config/loader.go to drop project.BaseURL before merging.
- Add regression tests for project-base_url rejection and env/CLI override.
- Update docs/CONFIG.md, docs/SECURITY.md, and AGENTS.md.
Project-level ./odek.json is untrusted, so it can no longer override the
API key, system prompt, or dangerous-policy. Global config, ODEK_* env
vars, and CLI flags remain valid sources.

- Ignore project BaseURL, APIKey, System, and Dangerous in LoadConfig.
- Print stderr warnings for each ignored project-level sensitive field.
- Update TestRun_WithProjectConfig to use env API key + project model.
- Add regression tests for api_key/system/dangerous rejection.
- Update docs/CONFIG.md, docs/SECURITY.md, and AGENTS.md.
MCP server children no longer inherit the full odek process environment.
They receive a minimal allowlist of safe variables plus explicit env
overrides, and keys matching secret patterns are always stripped.

- Replace inherited os.Environ() with an allowlist (PATH, HOME, LANG, etc.)
  in internal/mcpclient/client.go buildEnv.
- Block *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_CREDENTIAL,
  *_PRIVATE_KEY, and *_ACCESS_KEY from both inherited env and overrides.
- Always set cmd.Env so servers with no overrides still get sanitised env.
- Add regression tests for allowlist/blocklist behaviour.
- Update docs/MCP.md, docs/SECURITY.md, and AGENTS.md.
writeJSONAtomic previously used a predictable temp path (path + ".tmp")
and os.WriteFile, which follows symlinks. Replace it with
internal/fsatomic.WriteFile, which uses a random temp name with O_EXCL,
fsyncs data and directory, and renames over the target so a swapped-in
symlink is replaced rather than followed.

- Update internal/schedule/store.go to delegate to fsatomic.WriteFile.
- Add TestAtomicWrite_SymlinkTargetReplaced regression test.
- Update coverage tests that relied on the old predictable temp path to use
  read-only directories instead.
- Update docs/SECURITY.md and AGENTS.md.
The PID-file lock probed liveness with signals and could kill arbitrary
processes on non-Linux systems if an attacker planted a victim PID in
~/.odek/telegram.pid. Replace it with an advisory flock on
~/.odek/telegram.lock via the existing internal/flock module.

- Update cmd/odek/telegram.go acquireLock/release to use flock.Lock.
- Remove PID read/kill/write logic and the instanceLock struct.
- Clean up legacy telegram.pid file on successful lock acquisition.
- Add regression tests for lock-file creation, legacy PID cleanup, and
  no-kill behaviour.
- Update docs/TELEGRAM.md, docs/SECURITY.md, and AGENTS.md.
Finding #9 — Predictable session IDs:
- Generate 128-bit random session IDs (YYYYMMDD-<32 hex chars>).
- Add a 256-bit session-scoped AuthToken stored in the session JSON.
- Require the token via X-Session-Token header, session_token cookie, or
  auth_token WebSocket field for GET/DELETE/POST /api/sessions/<id> and
  WebSocket session resume.
- Add per-IP rate limiting (60/min) on session detail lookups.
- Redact AuthToken from the /api/sessions list endpoint.
- Update the Web UI to store and send tokens.

Finding #10 — send_message callback injection:
- Reject callback_data values that start with reserved internal prefixes
  (apr:, den:, trs:, clarify:, skill_save:, skill_skip:) in the
  send_message tool.
- Add defense-in-depth validation in the Telegram sender closure.
- Only user-facing cb: callbacks are allowed.

Tests and docs updated.
Skill content and retrieved session episodes were injected as plain
system messages, so a compromised or tainted skill/episode could pose as
trusted instructions. They now pass through the same nonce'd
<untrusted_content_*> wrapper used for tool output.

- Add UntrustedWrapper to odek.Config and plumb it into loop.Engine.
- In internal/loop/loop.go, apply the wrapper (when set) to skill and
  episode context before injecting system messages.
- Update all odek.New callers (CLI, REPL, serve, telegram, schedule,
  subagent) to pass wrapUntrusted.
- Remove the trusted-task-guide instruction from verbose skill banners.
- Add TestEngine_SkillAndEpisode_Wrapped regression test.
- Update docs/SECURITY.md and AGENTS.md.
Finding #12 — Resource.Search root traversal:
- Add validateSearchQuery to reject queries containing .., path separators,
  or absolute paths.
- Migrate walkAndMatch from filepath.Walk to filepath.WalkDir.
- Skip symlinked directories/files during traversal.
- Verify every match is withinRoot before returning it.
- Add regression tests for traversal and symlink skipping.

Finding #13 — Telegram media upload path traversal:
- Add internal/telegram/media_path.go with ResolveMediaPath, which restricts
  outbound media to cwd, ~/.odek/media, and os.TempDir().
- Reject symlinks and paths outside the allowlist.
- Apply ResolveMediaPath in internal/telegram/handler.go sendMedia,
  cmd/odek/telegram.go sendTelegramMedia, and internal/tool/send_message.go.
- send_message file argument must be absolute and pass allowlist checks.
- Add regression tests for allowed/rejected/symlink paths.
- Update docs/TELEGRAM.md, docs/SECURITY.md, and AGENTS.md.
Finding #14 — Session vector index rebuild:
- Validate each session filename-derived ID with ValidateSessionID before
  embedding it.
- Skip entries whose DirEntry.Type() is a symlink and double-check with
  os.Lstat.
- Add internal/session/vector_index_test.go regression tests.

Finding #15 — Episode vector index session_id trust:
- Treat index.json as untrusted input.
- Call session.ValidateSessionID(m.SessionID) before building the .md path.
- Add defense-in-depth checks for .. and path separators.
- Log warnings for rejected entries.
- Add regression tests in internal/memory/episode_index_test.go.

Update docs/SECURITY.md and AGENTS.md for both defenses.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 14, 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 7f5a076 Commit Preview URL

Branch Preview URL
Jun 14 2026, 07:07 PM

jkyberneees and others added 4 commits June 14, 2026 20:33
Follow-up fixes from running the AI verification protocol over this branch.
Each item was verified against the actual code path and covered by a
regression test.

- sandbox: forbidden mount prefixes (/home, /root, /var, /run) rejected every
  legitimate in-workdir volume mount on Linux (workdir lives under /home/<user>);
  skip a forbidden prefix the workdir itself sits under, keeping out-of-workdir
  rejection intact.
- sandbox: resolve the parent symlink chain and re-check containment so an
  intermediate symlinked directory cannot escape the working directory.
- danger: git global options that take a separate value token (-C, -c,
  --git-dir, ...) were mistaken for the subcommand, misclassifying
  `git -C <path> push` / `git -c <cfg> fetch` as non-egress; skip those values.
- telegram: documents were saved as "chat<id>_*", which the per-chat media
  quota glob ("*_chat<id>_*") never matched, letting documents bypass the cap;
  save as "doc_chat<id>_*".
- serve: compare session auth tokens with subtle.ConstantTimeCompare.
- config: a malicious project ./odek.json could set sandbox=false /
  sandbox_readonly=false to disable the sandbox; ignore the weakening direction.
- session: fail closed (panic) instead of minting a predictable timestamp-based
  session ID / 256-bit auth token if crypto/rand fails.
- audit: aggregate every <untrusted_content_*> blob in a tool message, not just
  the first, so a later-blob reused-resource injection is not missed.
- telegram tests: stop writing fixtures into the package source tree (t.Chdir
  into a temp dir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review follow-up. When unwrapUntrusted/untrustedSource (singular, with an
`src != ""` call-site guard) were replaced by untrustedSourcesAll, the
empty-source filter was dropped. A wrapper with source="" (reachable when a
wrapUntrusted caller passes an empty source, e.g. a browser snapshot with an
empty URL) then entered untrustedSources. In the audit divergence check,
isSource() does strings.HasPrefix(resource, ""), which is always true, so a
single empty-source blob marked every resource as a "source", emptied
untrustedResSet, and silently suppressed reused-resource injection detection
for the whole turn — defeating the multi-blob hardening the change introduced.

Restore the empty-source skip in untrustedSourcesAll and add a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…udit extraction

- Run sandbox ForbiddenMountPrefixes check on the symlink-resolved host path
  so it is composed on the same canonical path as the containment check.
- Fold unwrapUntrustedAll + untrustedSourcesAll into a single regex pass via
  extractUntrustedAll; audit.go now calls the combined helper per tool msg.
- Promote duplicated resolveDirSymlinks/withinRoot logic into a new shared
  internal/pathutil package and reuse it from internal/sandbox and
  internal/resource.
- Add pathutil tests and a single-pass extraction consistency test.

All existing tests plus race-detector runs pass.
- Add .golangci.yml with a pragmatic core-linter set (errcheck, gosimple,
  govet, ineffassign, staticcheck, unused). Test files are excluded from the
  noisiest rules; pre-existing production findings are explicitly excluded
  with comments so they can be removed incrementally.
- Fix the lint issue in internal/resource/resource.go (unchecked
  filepath.WalkDir error).

The CI workflow job will be added separately because this token lacks the
workflow OAuth scope.
@jkyberneees jkyberneees merged commit 408a38e into main Jun 14, 2026
7 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