Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
c0dc466
fix(loop): batch denial blocks only the checks shown on the refused card
jkyberneees Oct 11, 2026
7db458c
fix(loop): calls that never start leave plan check state untouched
jkyberneees Oct 11, 2026
aebf693
fix(loop): sort tool definitions by name for a stable cache prefix
jkyberneees Oct 11, 2026
7e97535
fix(loop): bound check_replace evidence-note descriptions to the resu…
jkyberneees Oct 11, 2026
f1e3041
fix(loop): reject check_replace replacements that duplicate a sibling id
jkyberneees Oct 11, 2026
0db9c5f
fix(loop): clamp check_replace justification to the revision reason cap
jkyberneees Oct 11, 2026
7985bd8
fix(loop): reopen a done step when check_replace installs a pending c…
jkyberneees Oct 11, 2026
8581088
fix(loop): split args_summary argv0 quote-aware so assignment values …
jkyberneees Oct 11, 2026
362ff37
fix(redact): redact AWS secret access key and session token
jkyberneees Oct 11, 2026
aea9b38
fix(redact): redact HTTP Basic Authorization credentials
jkyberneees Oct 11, 2026
ed9bab6
fix(redact): redact Bearer tokens in JSON/JS header objects
jkyberneees Oct 11, 2026
70c7d95
fix(redact): redact OAuth refresh_token values
jkyberneees Oct 11, 2026
af4d086
fix(redact): replace the longest known secret first
jkyberneees Oct 11, 2026
e15c50d
fix(guard): flag PKCS#8 and encrypted private keys in the local scan
jkyberneees Oct 11, 2026
041b737
fix(cleanup): keep pinned sessions out of the dry-run preview
jkyberneees Oct 11, 2026
56a6d7c
fix(cleanup): preview runtime-log expiry with the sweep's clamp
jkyberneees Oct 11, 2026
fbd888b
fix(maintenance): age out quarantined audit log copies
jkyberneees Oct 11, 2026
3ee8749
fix(audit): match untrusted sources on path boundaries
jkyberneees Oct 11, 2026
7b901f7
fix(logging): keep the Telegram allow-all warning's operation label
jkyberneees Oct 11, 2026
fca522f
perf(redact): bound the Basic-auth pass on large inputs
jkyberneees Oct 11, 2026
658820a
fix(redact): redact the X-Amz-Security-Token header and query param
jkyberneees Oct 11, 2026
247ef93
fix(serve): clear expired approvals from headless REST runs
jkyberneees Oct 11, 2026
8a9928f
fix(serve): count each wake once in the wake-on-complete rate limit
jkyberneees Oct 11, 2026
3a1bf00
fix(serve): release MCP servers and guard when agent setup fails
jkyberneees Oct 11, 2026
74ecdc3
fix(serve): read a run's session id under its lock in the subagent re…
jkyberneees Oct 11, 2026
75bff67
fix(serve): cancel in-flight headless runs on graceful shutdown
jkyberneees Oct 11, 2026
2b0eda0
fix(serve): redact a new session's title before truncating it
jkyberneees Oct 11, 2026
4cc0fa3
fix(serve): honour the trusted config and env sandbox opt-outs
jkyberneees Oct 11, 2026
7da259d
fix(serve): deny late approvals on a cancelled headless run at once
jkyberneees Oct 11, 2026
417376c
fix(serve): redact background-job command heads in GET /api/jobs
jkyberneees Oct 11, 2026
e3a9d4a
fix(serve): refuse headless runs once the shutdown sweep has begun
jkyberneees Oct 11, 2026
b9ecb5b
fix(introspect): config_view parses its JSON arguments
jkyberneees Oct 11, 2026
d444dc2
fix(file): patch diff shows the edited region
jkyberneees Oct 11, 2026
581fd8d
fix(file): glob and search_files walk a symlinked root directory
jkyberneees Oct 11, 2026
4f42647
fix(file): ** glob patterns keep bracket character classes
jkyberneees Oct 11, 2026
1840a7a
fix(browser): wrap the result page URL in the untrusted boundary
jkyberneees Oct 11, 2026
64d3f36
fix(http_request): gate mutating or credentialed requests as network_…
jkyberneees Oct 11, 2026
ede2f68
fix(file): patch matches multi-line text in CRLF files
jkyberneees Oct 11, 2026
798f399
fix(json_query): index nested arrays with chained [N][M]
jkyberneees Oct 11, 2026
286d6c3
fix(diff): report a final-newline-only difference
jkyberneees Oct 11, 2026
a6af37f
fix(bgproc): report killed/timeout for a 143/137 exit after a forced …
jkyberneees Oct 11, 2026
0daaa46
fix(config): merge planning.remind from global and project config
jkyberneees Oct 11, 2026
8468c12
fix(config): strip secrets.env inline comments after a tab
jkyberneees Oct 11, 2026
97b3352
fix(mcpclient): echo the Streamable HTTP Mcp-Session-Id
jkyberneees Oct 11, 2026
269e0c4
fix(mcpclient): send notifications/initialized on stdio
jkyberneees Oct 11, 2026
d446837
fix(mcpclient): answer server ping and reject other server requests
jkyberneees Oct 11, 2026
312eb5b
fix(repl): decode escape sequences from the buffered input
jkyberneees Oct 11, 2026
691f1d1
fix(skills): require --force to promote any project-dir skill
jkyberneees Oct 11, 2026
4713fc9
fix(subagent): fail closed on unrecognised trust labels
jkyberneees Oct 11, 2026
066eebb
fix(artifact): percent-encode file:// URIs odek builds for artifact refs
jkyberneees Oct 11, 2026
13cf43f
fix(skills): require a 3-letter stem for prefix keyword matches
jkyberneees Oct 11, 2026
82c10d4
test(mcpclient): cover notify, server-reply and session-id edge paths
jkyberneees Oct 11, 2026
e248c22
fix(skills): let short keywords match their plurals and inflections
jkyberneees Oct 11, 2026
209b6a8
fix(mcpclient): drop Mcp-Session-Id on cross-host redirects
jkyberneees Oct 11, 2026
4ba5a02
fix(mcpclient): keep server-request replies off the client request queue
jkyberneees Oct 11, 2026
d6875bf
fix(telegram): record daily token spend even when it overshoots the b…
jkyberneees Oct 11, 2026
00ab9fc
fix(telegram): announce each background-job exit to the chat once
jkyberneees Oct 11, 2026
a520e5b
fix(telegram): let exits join an open wake window at the spend cap
jkyberneees Oct 11, 2026
d734440
fix(telegram): cut verbose tool-progress arguments on a rune boundary
jkyberneees Oct 11, 2026
aa8a478
fix(telegram): split commands from arguments at any whitespace
jkyberneees Oct 11, 2026
5db78e0
fix(telegram): do not toast 'Approved' for a rejected approval press
jkyberneees Oct 11, 2026
583a76c
fix(telegram): escape backslashes for MarkdownV2 in prose and code
jkyberneees Oct 11, 2026
3b1091f
fix(telegram): never split a MarkdownV2 escape pair across chunks
jkyberneees Oct 11, 2026
81d90cc
fix(telegram): escape unpaired '*' instead of emitting an unclosed it…
jkyberneees Oct 11, 2026
9eb24c2
fix(schedule): do not catch up a job on re-enable after a disable
jkyberneees Oct 11, 2026
46450f0
fix(telegram): escape unpaired backticks instead of opening a code span
jkyberneees Oct 11, 2026
a3a86ad
fix(telegram): deliver the daily-budget overshoot warning as plain text
jkyberneees Oct 11, 2026
67381db
fix(telegram): bill daily token spend for failed and cancelled runs
jkyberneees Oct 11, 2026
9a8fcf3
fix(session): redact secrets in the per-session audit log
jkyberneees Oct 11, 2026
674037c
fix(session): rebuild a corrupt session index from the session files
jkyberneees Oct 11, 2026
769599e
fix(session): taint episodes that read through a symlink escaping the…
jkyberneees Oct 11, 2026
5648c6e
fix(memory): rebuild the episode recall index when its corpus changed
jkyberneees Oct 11, 2026
c7b9b7c
fix(memory): reject fact text with a partial entry separator at its edge
jkyberneees Oct 11, 2026
862f64c
fix(memory): re-check merged facts before merge-on-write persists them
jkyberneees Oct 11, 2026
b407b13
fix(memory): keep the extended-memory LLM rerank order in recall results
jkyberneees Oct 11, 2026
267cc6d
fix(memory): stop refitting the shared atom-index embedder under readers
jkyberneees Oct 11, 2026
3214cae
fix(memory): escape terminal control bytes in memory review listings
jkyberneees Oct 11, 2026
f6c582e
fix(session-search): match deep search on the single usable query token
jkyberneees Oct 11, 2026
bd14265
fix(session): fail saves loudly when index.json is not a regular file
jkyberneees Oct 11, 2026
5f9cdfd
fix(memory): store the new fact separately when its merge would be un…
jkyberneees Oct 11, 2026
091b3c9
fix(danger): classify git grep -O / --open-files-in-pager as code exe…
jkyberneees Oct 11, 2026
aff3869
fix(danger): classify git send-email program options and uploads
jkyberneees Oct 11, 2026
4ab7e0c
fix(danger): treat git credential plumbing as a secret read
jkyberneees Oct 11, 2026
ff72346
fix(danger): treat gpg secret-key export as a secret read
jkyberneees Oct 11, 2026
d690140
fix(danger): classify sqlite3 pipe dot-commands and edit() as code ex…
jkyberneees Oct 11, 2026
209e516
fix(danger): classify scripts fed to sqlite3 on stdin
jkyberneees Oct 11, 2026
6ac240c
fix(danger): path-classify sqlite3 writefile() and VACUUM INTO targets
jkyberneees Oct 11, 2026
1543e33
fix(danger): classify zip move mode and 7z -sdel as destructive
jkyberneees Oct 11, 2026
b373e60
fix(danger): classify moving a home directory away as system_write
jkyberneees Oct 11, 2026
9105064
fix(danger): classify permission and compression changes of a home di…
jkyberneees Oct 11, 2026
4c01ca1
fix(danger): classify alias definitions by the body they run
jkyberneees Oct 11, 2026
e427919
fix(danger): path-classify git format-patch output directories
jkyberneees Oct 11, 2026
cfe8a8a
fix(danger): classify git apply --unsafe-paths as a write outside the…
jkyberneees Oct 11, 2026
3cf3fdc
test(danger): cover edge spellings of the new classifier adapters
jkyberneees Oct 11, 2026
d25e449
fix(danger): track alias definitions and classify alias uses by expan…
jkyberneees Oct 11, 2026
e5cdd93
fix(danger): treat gpg --export-secret-ssh-key as a secret read
jkyberneees Oct 11, 2026
0340127
fix(danger): judge whole sqlite3 writefile/VACUUM INTO arguments
jkyberneees Oct 11, 2026
a90bf7e
fix(danger): path-classify sqlite3 write dot-commands everywhere
jkyberneees Oct 11, 2026
cd58d72
docs(agents): describe alias tracking and the sqlite3 write targets
jkyberneees Oct 11, 2026
2949429
fix(danger): expand aliases after time/! and scope the self-expansion…
jkyberneees Oct 11, 2026
00c2120
fix(danger): path-classify one-letter sqlite3 dot-commands, .trace an…
jkyberneees Oct 11, 2026
011b106
fix(danger): read sqlite3 writefile/VACUUM INTO across line breaks an…
jkyberneees Oct 11, 2026
861cee9
style(danger): factor the alias plain-rune check into a helper
jkyberneees Oct 11, 2026
dd91cbf
fix(audit): match untrusted sources on raw strings, not redacted keys
jkyberneees Oct 11, 2026
ee63ac2
fix(audit): match persisted ingest resources against redacted sources
jkyberneees Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ ReAct cycle: observe → think → act → repeat.
- LLM returns tool calls or a final answer.
- **Parallel tool execution** — independent tool calls run concurrently (`max_tool_parallel`, default: 4).
- **Plan tool** — built-in `plan` tool (docs/PLANNING.md) for multi-step work: steps, optional acceptance checks, revise/check_replace lifecycle. Sub-agents never own or mutate parent plans. Check recording (`completion_checks.go`) consumes engine outcomes only — a check passes when its declared tool actually ran and succeeded, never from claims inside tool output; a failed or unclassifiable effect invalidates earlier checks in the same batch.
- **Batch approval gate** — multiple risky tools shown in one prompt. `classifyToolCall` classifies each individual `shell` command, each `patch`/`write_file` target, and the `browser` tool; shows full commands; withholds blanket `SetTrustAll` when unclassifiable tools (incl. MCP tools, classified `unknown`) remain.
- **Batch approval gate** — multiple risky tools shown in one prompt. `classifyToolCall` classifies each individual `shell` command, each `patch`/`write_file` target, the `browser` tool, and `http_request` (mutating method or credential header → `network_upload`, as for curl); shows full commands; withholds blanket `SetTrustAll` when unclassifiable tools (incl. MCP tools, classified `unknown`) remain.
- **Tool-failure recovery** — retry transient errors, skip permanent failures, continue without crashing. Stall detection: 3 consecutive identical tool calls inject a corrective hint + fire `tool_recovery` — a hint, never aborts the run.
- **Context-limit protection** — graduated trimming near the context window: old large tool results replaced with markers (4 most recent kept intact), then oldest turn groups dropped atomically (tool messages stay grouped with their parent). The protected head (system prompt, memory block, compaction digest, original task) is never dropped. The digest (body capped at an eighth of the budget) and the trim warning are reserved against the budget before groups are dropped, so the post-trim history fits. Token estimator counts tool schemas + reasoning content; safety margin self-tightens when provider-reported tokens exceed estimates (`margin_calibrated` signal). `trimToSurvival` handles provider context-length errors. **Rolling compaction** (on by default; `compaction: false` / `ODEK_COMPACTION=false` / `--no-compaction`) sketches dropped groups extractively into a rolling digest immediately, then a thinking-off side call replaces that sketch with a model digest on a later iteration if it arrives.
- **Final-answer verification pass** (`internal/loop/verify.go`, optional) — after a candidate final answer, a side-call checks the answer against the run's evidence and can trigger corrective cycles. Config: `verify` section (`enabled`, `mode` hint|strict|off, optional cheaper `model`, `max_cycles` ≤ 3, `max_tokens`); default off, `hint` when enabled. Sub-agents are opted in only via an explicit `subagent.verify` section — `delegate_tasks` never enables it implicitly, so N children cannot multiply verify cost.
Expand Down Expand Up @@ -215,7 +215,7 @@ Layered prompt-injection / approval-fatigue defenses. The full per-mitigation li

- **Untrusted-content boundary** (`cmd/odek/untrusted.go`) — every externally-sourced tool result (browser, file/shell/search tools, MCP, session_search, @-refs, --ctx, attachments, artifact_read) is wrapped in a per-call nonce'd `<untrusted·content_<nonce>>` tag; tool-result delimiters are also nonce'd (`internal/loop`). Skill/episode context injected into the system prompt is wrapped too. The per-session audit log (`cmd/odek/audit.go`) records every ingest and flags divergence between user-mentioned resources and agent actions.
- **Provenance gates** — tainted memory episodes are stored but never auto-replayed (the episode taint is sticky session state, `Session.EpisodeUntrusted`, so trimming cannot launder it); skills from untrusted sources (imported via URI, project `./.odek/skills/`) are pinned `NeedsReview` until `odek skill promote --force` is run after human review, excluded from trigger matching, and protected against frontmatter tampering. Load-time and import-time skill bodies go through the injection scanner (`guard.ScanContentWithScope`). `odek` self-invocation via shell is `system_write` so the agent can't reach its own trust mutations.
- **Danger classifier** (`internal/danger/classifier.go`; layered design in its package doc) — quote-aware normalization ($IFS, ANSI-C, brace lists/sequences, substitutions, heredocs, wrappers, backslashes, basenames); compound commands (`for`/`while`/`if`/`case`/groups/functions/`[[ ]]`) parsed so every simple command inside is classified, unterminated constructs and quotes classify `unknown`. Wrappers share one option grammar (`wrapper_grammar.go`) incl. command-string options (`env -S`, `script -c`, `flock -c`). Covers awk/sed/editor escapes, pipe-fed xargs composition, root-level mutation targets, git data-loss verbs, `git -c`/config code exec, find/rsync destructive flags, env dumps, exec-controlling `export`s, shell operand/redirect path classification (writes to shell rc files, ~/.ssh, ~/.odek escalate to system_write). `network_upload` (default prompt, ranked between `network_egress` and `code_execution`) splits uploads, credentialed/mutating requests, listeners and tunnels from plain egress; `gh` is classified by command and verb (`gh_adapter.go`). Ordinary git verbs escalate to `code_execution` only when the targeted repository is armed (hooks, fsmonitor, filters/drivers, editors, includes); an undeterminable repo, `GIT_*` overrides, sudo wrappers or a hook written earlier in the same command fail closed (`git_repo_arming.go`). Denylist entries are token prefixes matched at every command position, with tool global options stripped and known variables resolved — not raw string prefixes; here-strings and static echo/printf text piped into a shell, also through pass-through stages (cat, tee, sort, uniq, tac, head, tail), are scanned too, and the recursive scan is memoised depth-aware so nested eval/find -exec chains stay bounded. Secret-shaped env vars (`$NAME`, `${!v}`, `printenv`, `os.environ`) and credential files (by basename, extension or directory) are `system_write` (`secret_reads.go`). Trust anchors under `~/.odek` are write-protected from generic file tools. The read ledger is fingerprinted (`WasReadFresh`: post-read mutation re-fires the unread-script gate), covers scripts delivered through pipes, substitutions, `eval`, `find -exec` and program-file options, is bounded (4096 paths per session, 1024 sessions, LRU eviction only ever removes a licence) and dropped per session with `danger.ForgetReadLedger` (serve session delete, Telegram reset, schedule run end); unread-script approvals carry a pre-exec injection-scan enrichment incl. single-layer base64/hex decode (`cmd/odek/unreadscan.go`, scan never populates the ledger). Quoted operator-shaped words (`';'`, `'|'`, a decoded `$'\n'`) are arguments in every segment/pipe split (literal-marked tokens), the denylist scan examines each distinct command line once (bounded on nested `eval`/`find -exec`) and also reads here-strings and static `echo`/`printf` pipes into a shell, and a home reached through a symlink counts as the same home. Fuzz invariants in `monotonicity_fuzz_test.go` (suffix wipe never hidden, pipe into shell at least `code_execution`, harmless prefix never lowers a verdict, bounded time).
- **Danger classifier** (`internal/danger/classifier.go`; layered design in its package doc) — quote-aware normalization ($IFS, ANSI-C, brace lists/sequences, substitutions, heredocs, wrappers, backslashes, basenames); compound commands (`for`/`while`/`if`/`case`/groups/functions/`[[ ]]`) parsed so every simple command inside is classified, unterminated constructs and quotes classify `unknown`. Wrappers share one option grammar (`wrapper_grammar.go`) incl. command-string options (`env -S`, `script -c`, `flock -c`). Covers awk/sed/editor escapes, pipe-fed xargs composition, root-level mutation targets, git data-loss verbs, `git -c`/config code exec, find/rsync destructive flags, env dumps, exec-controlling `export`s, shell operand/redirect path classification (writes to shell rc files, ~/.ssh, ~/.odek escalate to system_write). `network_upload` (default prompt, ranked between `network_egress` and `code_execution`) splits uploads, credentialed/mutating requests, listeners and tunnels from plain egress; `gh` is classified by command and verb (`gh_adapter.go`). Ordinary git verbs escalate to `code_execution` only when the targeted repository is armed (hooks, fsmonitor, filters/drivers, editors, includes); an undeterminable repo, `GIT_*` overrides, sudo wrappers or a hook written earlier in the same command fail closed (`git_repo_arming.go`). Denylist entries are token prefixes matched at every command position, with tool global options stripped and known variables resolved — not raw string prefixes; here-strings and static echo/printf text piped into a shell, also through pass-through stages (cat, tee, sort, uniq, tac, head, tail), are scanned too, and the recursive scan is memoised depth-aware so nested eval/find -exec chains stay bounded. Secret-shaped env vars (`$NAME`, `${!v}`, `printenv`, `os.environ`), credential files (by basename, extension or directory) and credential stores read through their own tooling (`git credential …`, `gpg --export-secret-*`) are `system_write` (`secret_reads.go`). Aliases are tracked across the command (`alias.go`): a use is classified as its expansion (body plus remaining words) alongside the literal command, all bodies are kept and `unalias` removes nothing; an unused definition runs nothing. sqlite3 program launchers (abbreviated `.shell`/`.system`, `|cmd` dot-command arguments, `edit()`, scripts fed on stdin) are `code_execution`, and `writefile()`/`VACUUM INTO` paths (whole-literal only, else `unknown`) and write dot-command operands are write targets. Trust anchors under `~/.odek` are write-protected from generic file tools. The read ledger is fingerprinted (`WasReadFresh`: post-read mutation re-fires the unread-script gate), covers scripts delivered through pipes, substitutions, `eval`, `find -exec` and program-file options, is bounded (4096 paths per session, 1024 sessions, LRU eviction only ever removes a licence) and dropped per session with `danger.ForgetReadLedger` (serve session delete, Telegram reset, schedule run end); unread-script approvals carry a pre-exec injection-scan enrichment incl. single-layer base64/hex decode (`cmd/odek/unreadscan.go`, scan never populates the ledger). Quoted operator-shaped words (`';'`, `'|'`, a decoded `$'\n'`) are arguments in every segment/pipe split (literal-marked tokens), the denylist scan examines each distinct command line once (bounded on nested `eval`/`find -exec`) and also reads here-strings and static `echo`/`printf` pipes into a shell, and a home reached through a symlink counts as the same home. Fuzz invariants in `monotonicity_fuzz_test.go` (suffix wipe never hidden, pipe into shell at least `code_execution`, harmless prefix never lowers a verdict, bounded time).
- **Plan-check honesty** — plan acceptance checks record outcomes from actual engine observation only; failed or unclassifiable tool effects invalidate earlier checks in the same batch, so a check can never be satisfied by a claim inside tool output.
- **Approval friction** — TTY/WS/Telegram approvers engage friction after 3 same-class approvals in 60s (type `approve`, pause, trust shortcut hidden); `destructive`/`blocked`/`unknown` never get trust shortcuts. TTY prompts are process-wide serialized. Everything shown in an approval (TTY/WebSocket/Telegram prompts, batch cards, MCP/sandbox approval prompts, denial error strings) goes through `danger.SanitizeForDisplay`/`SanitizeInline`, which escape control, bidi and invisible characters so the human reads the bytes that run; the read_only non-interactive carve-out is keyed on the native tool name, never the model-supplied description.
- **Sub-agent caps** — `delegate_tasks` carries trust_level + max_risk enforced via the sub-agent's DangerousConfig; MCP tools withheld from untrusted sub-agents; API keys handed off via unlinked-tempfile FD, never env. Sub-agent results over ~2000 chars are delivered as registry-backed artifacts the parent reads via `artifact_read`.
Expand Down
79 changes: 69 additions & 10 deletions cmd/odek/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/loop"
"github.com/BackendStack21/odek/internal/redact"
"github.com/BackendStack21/odek/internal/session"
)

Expand Down Expand Up @@ -59,6 +60,8 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
var actionText strings.Builder // agent actions: tool calls + final response
var untrustedBodies strings.Builder
var untrustedSources []string
// Sources read back from the audit store were persisted redacted.
var persistedSources []string
var recordedResources []string
ingestedUntrusted := false
lastAssistantContent := ""
Expand All @@ -70,7 +73,7 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
for _, ingest := range prior.Ingests {
if ingest.Turn == turn {
ingestedUntrusted = true
untrustedSources = append(untrustedSources, ingest.Source)
persistedSources = append(persistedSources, ingest.Source)
recordedResources = append(recordedResources, ingest.Resources...)
}
}
Expand Down Expand Up @@ -118,31 +121,56 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
// We exclude resources that match the source of the untrusted content
// (e.g. a fetched page mentioning its own URL) to avoid false positives
// for legitimate user-requested fetches.
isSource := func(r string) bool {
lr := strings.ToLower(r)
// The audit store persists resources redacted, so resource keys use the
// redacted, lower-cased form of both sides. The source exclusion compares
// each resource in the form it was captured: raw resources from this
// turn's messages against raw sources, never their redacted forms, since
// redaction swallows the rest of a URL after a token and two different
// URLs on one host can redact to the same text. Persisted resources are
// already redacted, so they are matched against the redacted sources.
auditKey := func(r string) string { return strings.ToLower(redact.RedactSecrets(r)) }
isRawSource := func(r string) bool {
for _, s := range untrustedSources {
ls := strings.ToLower(s)
if lr == ls || strings.HasPrefix(lr, ls) || strings.HasPrefix(ls, lr) {
if auditOpsResourceMatchesSource(r, s) {
return true
}
}
for _, s := range persistedSources {
if !strings.Contains(s, "[REDACTED]") && auditOpsResourceMatchesSource(r, s) {
return true
}
}
return false
}
isRedactedSource := func(r string) bool {
lr := auditKey(r)
for _, s := range untrustedSources {
if auditOpsResourceMatchesSource(lr, auditKey(s)) {
return true
}
}
for _, s := range persistedSources {
if auditOpsResourceMatchesSource(lr, auditKey(s)) {
return true
}
}
return false
}
untrustedResSet := make(map[string]bool)
for _, r := range session.ResourcesIn(untrustedBodies.String()) {
if !isSource(r) {
untrustedResSet[strings.ToLower(r)] = true
if !isRawSource(r) {
untrustedResSet[auditKey(r)] = true
}
}
for _, r := range recordedResources {
if !isSource(r) {
untrustedResSet[strings.ToLower(r)] = true
if !isRedactedSource(r) {
untrustedResSet[auditKey(r)] = true
}
}
var untrustedResources []string
seen := make(map[string]bool)
for _, r := range session.ResourcesIn(actionText.String()) {
lr := strings.ToLower(r)
lr := auditKey(r)
if untrustedResSet[lr] && !seen[lr] {
seen[lr] = true
untrustedResources = append(untrustedResources, r)
Expand All @@ -165,6 +193,37 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
_ = store.RecordTurn(sessionID, at)
}

// auditOpsResourceMatchesSource reports whether resource r is the untrusted
// content's own source s (case-insensitive): equal, or one lies under the
// other on a path boundary — the longer continues with '/', '?' or '#', or
// the shorter ends with '/'. A raw string prefix is not enough: the
// look-alike host https://news.example.com.collector.net extends the source
// https://news.example.com without being part of it.
func auditOpsResourceMatchesSource(r, s string) bool {
lr, ls := strings.ToLower(r), strings.ToLower(s)
if lr == "" || ls == "" {
return false
}
if lr == ls {
return true
}
short, long := ls, lr
if len(lr) < len(ls) {
short, long = lr, ls
}
if !strings.HasPrefix(long, short) {
return false
}
if strings.HasSuffix(short, "/") {
return true
}
switch long[len(short)] {
case '/', '?', '#':
return true
}
return false
}

// auditCmd handles `odek audit <session-id>` and `odek audit --list`.
// Read-only: it never modifies the audit log. Output is JSON to stdout
// so the caller can pipe through jq / their tool of choice.
Expand Down
Loading
Loading