Skip to content

Security hardening: injection-study findings — P0 fixes, persistence/unread-exec gates, read_only default, sandbox default-on - #145

Merged
jkyberneees merged 11 commits into
mainfrom
fix/sec-benchmark-hardening
Aug 26, 2026
Merged

Security hardening: injection-study findings — P0 fixes, persistence/unread-exec gates, read_only default, sandbox default-on#145
jkyberneees merged 11 commits into
mainfrom
fix/sec-benchmark-hardening

Conversation

@jkyberneees

Copy link
Copy Markdown
Contributor

Summary

Addresses the indirect-prompt-injection benchmark findings end to end: all four P0 bugs, all five hardening gaps, plus the observability the study needed but didn't have. Every change carries regression tests tied to the observed failure shape; the branch survived a hostile pre-merge review that caught one real licensing hole (fixed, tested).

P0 — bugs

  • Unknown CLI flags are a hard error, never folded into the task text (run/continue/repl; -- separator for dash-prefixed tasks). Closes the silent-prompt-corruption + argv injection vector.
  • odek --version / -v alias the version subcommand.
  • Stable call_id on TOOL CALL/RESULT headers in session show and on tool_call_started/completed/failed JSONL events — batched parallel calls can be paired programmatically (the study's parser mis-paired them and scored three real compromises as clean).
  • Auditable events: args_summary (argv0 / target path / URL host / danger class) always present; --events-include-args opts into raw secret-redacted args for incident review.

Hardening

  • persistence risk class — deferred-execution write targets (shell profiles, .envrc, git hooks, CI workflows, cron/systemd/launchd, npm pkg set lifecycle hooks) plus content sniffing for package.json install scripts and conftest.py autouse fixtures. Ranked above system_write, never session-trust-shortcuttable. Reads stay frictionless (ClassifyPathWrite is write-only).
  • unread_exec gate — executing a repo-supplied script whose contents were not fully read this session prompts per-script. Session read ledger; failed/partial/redirected reads license nothing ("cat errored → ran the file" stays gated). Intercepts approval even when code_execution is allowed/trusted.
  • non_interactive: "read_only" is the new headless default: inspection proceeds, mutations fail closed. Explicit invalid values still fail closed to deny. Containment that survives contact with a deadline.
  • Sandbox defaults ON for run/continue/repl with a loud unsandboxed fallback when Docker is unavailable; --no-sandbox/ODEK_NO_SANDBOX=1 opt out; ODEK_REQUIRE_SANDBOX=1 outranks everything; continue pins the session's original posture.
  • Reply/ledger reconciliation — a final reply claiming "blocked"/"no changes" after mutating calls completed gets an odek-attributed nonce'd consistency notice + reply_ledger_mismatch signal.

Review pass

CRIT-001 (cat payload.sh > run.sh licensing execution of the never-seen copy), HIGH-001/002/003 (partial reads, spoofable notice header, per-entry parallel_shell ledger), MED-003/004 and the actionable LOWs are all fixed with tests. Two deliberate policy calls documented in SECURITY.md (Dockerfile-degradation fallback; process-scoped read ledger on long-lived surfaces).

Test plan

  • go test ./... -count=1 — full suite green
  • go test -race on danger/loop/events/config — clean
  • golangci-lint run ./internal/... ./cmd/... — 0 issues
  • Docs updated: SECURITY.md (classes, defaults, matrix rows), EXTENSIONS.md (call_id, args_summary), CONFIG.md, CLI.md, CHEATSHEET.md

🤖 Generated with Kai

…P0-2)

P0-1: parseRunFlags/parseContinueArgs/parseReplFlags silently folded
unrecognised flags into the task text. A typo'd or version-drifted flag
corrupted the prompt with no signal, and anything that controls argv
(wrapper scripts, CI job definitions, Makefile targets) gained a
prompt-injection vector into the CLI itself.

- unknown flag-shaped args now exit non-zero naming the offender
- explicit "--" separator passes everything after it verbatim
- dangling --id/--external-ref values error instead of becoming task text
- trailing standalone flags after the task still work

P0-2: `odek --version` (and -v) now alias the version subcommand —
the form packaging scripts and CI preflights reach for first.
…how + events (P0-3)

Parallel tool calls rendered as CALL,CALL,…,RESULT,RESULT,… with nothing
tying a result to its call. A transcript parser pairing them sequentially
attaches another call's output to a call — the injection study's harness
did exactly that and scored three real compromises as clean.

- session show emits a #label on both TOOL CALL and TOOL RESULT headers;
  provider tool-call IDs preferred, deterministic synthetic labels
  (m<msg>-c<n>) when omitted, '#unmatched' for orphaned results
- tool_call_started/completed/failed JSONL events carry a matching
  call_id (provider ID verbatim, or it<iter>-call<idx> fallback)
…args opt-in (P0-4)

--events-jsonl redacts arguments to args_bytes + args_sha256, so the
stream alone cannot answer the first question an incident review asks;
reconstruction requires session show, and the session may already be
deleted.

- tool_call_started now always carries args_summary: argv[0] (env
  assignments skipped), target path(s) / URL host, and the danger class —
  structure without argument content, secrets still redacted
- --events-include-args (Config.EventsIncludeArgs /
  Engine.SetEventsIncludeArgs) opts the stream into raw redacted args
…ts (H-5)

Scenario 20 asked the agent to append one documented line to a shell
profile: not destructive, not egress, not an install, executes nothing
in-session — so nothing escalated and the hook was planted in 9/9 draws
while Claude Code refused the same write in both postures.

New 'persistence' class (rank above system_write, default prompt, never
eligible for trust shortcuts — the payload fires outside the session
that granted trust), keyed on write targets rather than command shape:

- shell profiles, fish config, direnv (.envrc), .git/hooks/*,
  .github/workflows/*, .gitlab-ci.yml, cron (crontab + /etc/cron.*),
  systemd system+user units, macOS LaunchAgents/Daemons, profile.d
- crontab (non-listing), npm pkg-set/set-script lifecycle hooks,
  jq .scripts rewrites of package.json
- content sniffing for package.json install lifecycle scripts and
  conftest.py autouse fixtures (write_file / patch / batch_patch)

Reads keep the existing classifier — reading a CI workflow must stay
frictionless. ClassifyPathWrite is the write-aware entry point; the
shell classifier escalates redirect/operand targets before SystemWrite.
…(H-6)

Scenarios 21/22 were the only ones that executed on the capable tier,
and the payload sat inside the correct, documented fix for a real
failure, firing on the verification run. 12 of 14 executing runs flagged
the tamper in the same reply — model detection is sound, timing is not,
so the control must be mechanical:

- session read ledger (danger.RecordRead/WasRead): read_file/batch_read
  and successful writes mark paths; shell cat/head/tail viewers record
  operands on success. Failed reads never license execution — the
  observed 'cat errored, ran the file instead' failure stays gated.
- UnreadScriptTargets: verb-aware scan (interpreters, source, direct
  invocation) of script-file operands that exist and were not read
- new unread_exec class: SystemWrite tier, default prompt, never
  trust-shortcuttable; intercepts checkApproval/parallel_shell even
  when code_execution is allowed or trusted; explicit
  'unread_exec': 'allow' override still honored
- loop batch gate shows unread_exec in the approval card
The deny posture refused every gated call including reads: 51 study runs
never reached the payload because the agent couldn't ls, tree, or cat.
Real containment, but not resistance — and containment via inability
gets flipped to 'allow' to meet a deadline, taking every protection with
it.

- new Action read_only (non_interactive: "read_only"): without a TTY,
  Safe-classified shell commands and native read tools over ordinary
  paths proceed; writes, exec, egress, and sensitive-location reads
  fail closed
- unset non_interactive now defaults to read_only; explicitly set
  INVALID values still fail closed to deny (a typo must never loosen
  the gate); explicit deny/allow unchanged
- config loader accepts and validates the new value
odek shipped sandbox support off by default with an honest warning —
meaning the one control that actually contains the 'ran attacker-
controlled code' class (where model quality does not help) had to be
discovered. Now it must be deliberately given up:

- run/continue/repl: sandbox defaults ON when no layer sets it
- Docker unavailable (or unapproved project Dockerfile): implicit runs
  degrade to unsandboxed with a loud notice — explicit --sandbox keeps
  the hard-fail; ODEK_REQUIRE_SANDBOX=1 makes any fallback fatal
- --no-sandbox flag + ODEK_NO_SANDBOX=1 opt out; config loader tracks
  SandboxExplicit through the layer merge
- continue pins the session's original sandbox posture instead of
  inheriting the new default (no mid-conversation containment flips)
- serve/telegram/subagent/mcp keep explicit-only semantics
- TestMain sets ODEK_NO_SANDBOX=1 so spawned binaries stay hermetic
Observed draw: odek wrote the persistence hook, then read the payload,
correctly identified the injection, and told the user 'The setup is
blocked.' The setup was not blocked — the hook was already on disk.
Detection that lands after the side effect is reporting; a reply that
misreports the side effect is worse than silence because it actively
stops the user from looking.

Before a final answer goes out, its claims are diffed against the
run-scoped ledger of completed mutating tool calls (write_file/patch/
batch_patch successes; shell/parallel_shell commands classified
local_write or higher; failures excluded). On conflict, an
explicitly-attributed odek consistency notice naming the actions is
appended to the reply and a reply_ledger_mismatch signal fires.
Conservative claim patterns — accurate replies and read-only runs are
never annotated.
…ervability

- SECURITY.md: persistence + unread_exec classes documented with
  rationale; non_interactive read_only default; sandbox default-on
  semantics; reply/ledger reconciliation; CLI argument discipline;
  repo-immutable policy promoted to a named invariant; attack-vector
  matrix rows for every new mitigation; taint-tracking noted as the
  next step beyond the labelling boundary
- EXTENSIONS.md: call_id correlation, args_summary structure,
  --events-include-args opt-in
- CONFIG.md: sandbox default flip + opt-outs, schedule safety floor
  extended with persistence/unread_exec
- CLI.md: --version aliases, strict flag errors + '--' separator,
  --no-sandbox, --events-include-args
- schedule runner: persistence/unread_exec join the non-overrideable
  deny floor
…ity, sandbox enforcement gaps

From the pre-merge review of this branch:

- CRIT-001: recordViewerReads recorded redirect targets and piped/partial
  operands — 'cat payload.sh > run.sh' licensed executing the never-seen
  copy, silently re-enabling the exact scenario the unread_exec gate
  exists to stop. Any pipe/redirect in a viewer command now disables
  recording entirely.
- HIGH-001: read_file/batch_read record only full-file reads; a partial
  offset/limit window over a longer file no longer licenses execution.
- HIGH-002: consistency-notice header carries an unpredictable
  [ref <nonce>] so model output cannot pre-forge the runtime's
  attribution; event-stream signal remains the authoritative record.
- HIGH-003: mutation ledger evaluates parallel_shell per entry (one
  failed sibling no longer erases successful neighbors); shell failure
  detection is prefix-only so stdout mentioning 'error' keeps real
  mutations; file tools keep the JSON-error shape.
- MED-003: ODEK_REQUIRE_SANDBOX=1 now outranks every opt-out, including
  explicit --no-sandbox — contradictory operator instructions fail
  loudly instead of guessing.
- MED-004: batch approval card names the gating unread scripts inline.
- LOW: args_summary classes now match what the gates actually enforce
  (script gate for shell, write-aware for write tools); rune-boundary
  truncation; unread_exec config participates as deny-wins/both-allow
  rather than doc-only override; docs updated for ledger process scope
  and the deliberate Dockerfile-degradation policy call.
@jkyberneees
jkyberneees merged commit ae24e44 into main Aug 26, 2026
10 checks passed
@jkyberneees
jkyberneees deleted the fix/sec-benchmark-hardening branch August 26, 2026 18:33
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