Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
125 commits
Select commit Hold shift + click to select a range
b684bbb
fix(loop): batch card classifies diff paths and request URLs like the…
jkyberneees Oct 12, 2026
a8de13b
fix(loop): check_replace advances the check epoch
jkyberneees Oct 12, 2026
e6278d4
fix(loop): args_summary argv0 keeps substituted assignment values whole
jkyberneees Oct 12, 2026
35146d1
fix(loop): mutating bg_start commands enter the reconciliation ledger
jkyberneees Oct 12, 2026
ee89751
docs(security): batch card classification of diff, http_request and b…
jkyberneees Oct 12, 2026
cf9385b
fix(loop): argv0 word splitting skips whole nested substitutions
jkyberneees Oct 12, 2026
d3ef85f
fix(loop): browser snapshot is safe on the batch card
jkyberneees Oct 12, 2026
7a4c741
fix(loop): transcribe batch card includes provider upload egress
jkyberneees Oct 12, 2026
0fabcee
fix(mcpclient): enforce small stdio max_response_bytes exactly
jkyberneees Oct 12, 2026
3825a4d
fix(mcpclient): fail fast once the stdio reader has exited
jkyberneees Oct 12, 2026
f69c99d
fix(mcpclient): keep artifact metadata when multi-item results are tr…
jkyberneees Oct 12, 2026
254420d
fix(mcpclient): reject tools/list responses with duplicate tool names
jkyberneees Oct 12, 2026
5460d0e
fix(mcp): keep only regex-shaped pattern keywords in the provider schema
jkyberneees Oct 12, 2026
3dc7184
fix(agent): refuse non-regular AGENTS.md and bound the read
jkyberneees Oct 12, 2026
1e71bf4
fix(llmclient): persist and replay every signed thinking block
jkyberneees Oct 12, 2026
7b12b79
fix(session): drop signed thinking when redaction alters its text
jkyberneees Oct 12, 2026
9080ef3
fix(mcp): lift word-like patterns out of the provider schema
jkyberneees Oct 12, 2026
f87155b
fix(serve): approval answers are one-shot so a dropped second answer …
jkyberneees Oct 12, 2026
775783f
fix(serve): reject unknown or unauthenticated sessions before taking …
jkyberneees Oct 12, 2026
abf0f44
fix(serve): shorten backs up to a rune boundary so titles and task la…
jkyberneees Oct 12, 2026
bd8d9eb
fix(serve): markdown export renders task, model and tool names inline…
jkyberneees Oct 12, 2026
257808b
fix(serve): rate-limit rejected lookups on every session-scoped endpoint
jkyberneees Oct 12, 2026
05efa22
fix(serve): session listing fallback returns only allowlisted metadata
jkyberneees Oct 12, 2026
4fd4238
fix(resource): @-file references refuse trust anchors and credential …
jkyberneees Oct 12, 2026
c119eed
fix(resource): @-file autocomplete does not list protected paths
jkyberneees Oct 12, 2026
2a60226
fix(serve): POST /api/prompt bad-token rejection burns the session lo…
jkyberneees Oct 12, 2026
a415d87
fix(config): compare full MCP transport identity before inheriting au…
jkyberneees Oct 12, 2026
0817810
fix(agent): strip quoted/listed pillar imitations and close open fenc…
jkyberneees Oct 12, 2026
2d8d603
fix(config): project max_concurrency may only lower the sub-agent lim…
jkyberneees Oct 12, 2026
eeb536d
fix(skills): record project skill promotion under the frontmatter name
jkyberneees Oct 12, 2026
8fb7206
fix(subagent): never follow staged symlinks when relocating artifacts
jkyberneees Oct 12, 2026
76bc256
fix(subagent): scope artifact registry and artifact_read to the ownin…
jkyberneees Oct 12, 2026
35d3ac2
fix(subagent): children of a sandboxed parent must sandbox themselves
jkyberneees Oct 12, 2026
5c48633
fix(config): never expand host env vars in the untrusted project config
jkyberneees Oct 12, 2026
14ac8ab
test(config): cover MCP transport identity comparison
jkyberneees Oct 12, 2026
c8c7c8c
fix(agent): strip numbered-list prefixed pillar heading imitations
jkyberneees Oct 12, 2026
37e07ad
docs(config): project config is not subject to ${VAR} substitution
jkyberneees Oct 12, 2026
0f6c329
fix(config): a project MCP entry cannot re-enable an operator-disable…
jkyberneees Oct 12, 2026
af67f03
fix(redact): cover bare token/secret/pass keys, arrow/flag/netrc/XML …
jkyberneees Oct 12, 2026
4fc0af0
fix(audit,untrusted,unreadscan): one-directional source match, escape…
jkyberneees Oct 12, 2026
fb04c4c
fix(redact): require secret-like values for bare keys; XML attributes…
jkyberneees Oct 12, 2026
3cf7af8
fix(loop): linear, name-specific untrusted tag look-alike neutralising
jkyberneees Oct 12, 2026
28bd25f
fix(session_search): scope Telegram session_search to the calling cha…
jkyberneees Oct 12, 2026
ed08764
fix(memory): list_quarantine and list_pending_review taint the sessio…
jkyberneees Oct 12, 2026
eef1a56
fix(audit): redact the whole body before extracting audit resources
jkyberneees Oct 12, 2026
a1c534d
fix(session): redact Buffer on save; remove run lock files with their…
jkyberneees Oct 12, 2026
93bf9be
fix(memory): redact secrets in buffer, episodes, facts and atoms
jkyberneees Oct 12, 2026
fc4d740
fix(session): AcquireExecution verifies the locked file is still the …
jkyberneees Oct 12, 2026
3687248
fix(ssrf): classify URL hosts after UTS-46 mapping before the policy …
jkyberneees Oct 12, 2026
91cf478
fix(danger): fold ~/.odek trust-anchor names like case-insensitive fi…
jkyberneees Oct 12, 2026
67b3563
fix(file_tool): block Unicode case-fold aliases of ~/.odek trust anchors
jkyberneees Oct 12, 2026
b89116e
fix(http_request): drop credential headers on cross-host redirects
jkyberneees Oct 12, 2026
1e7f351
fix(transcribe): wrap the plain-text whisper fallback and stderr as u…
jkyberneees Oct 12, 2026
3e60244
fix(transcribe): cap whisper and llama-mtmd stdout while reading it
jkyberneees Oct 12, 2026
550bc8c
fix(speech): settle the budget reservation taken for the request timeout
jkyberneees Oct 12, 2026
0933c53
fix(bg): evaluate the bg_start approval gate against the session read…
jkyberneees Oct 12, 2026
3f8bb9c
fix(bg): redact the command head in job-exit notices
jkyberneees Oct 12, 2026
7322422
fix(telegram): redact commands in the /jobs chat listing
jkyberneees Oct 12, 2026
a369ef1
fix(search_files): wrap skipped entries as untrusted
jkyberneees Oct 12, 2026
a2bc68f
fix(browser): cap title, element text and link URL size per snapshot
jkyberneees Oct 12, 2026
b8aa541
docs(security): document IDN host mapping and redirect credential str…
jkyberneees Oct 12, 2026
d322413
fix(danger): fold case consistently in home, credential and persisten…
jkyberneees Oct 12, 2026
1fcaf2d
fix(file_tool): compare the ~/.odek prefix with a fold-aware, length-…
jkyberneees Oct 12, 2026
4253f34
fix(http_request): also drop credential headers on an https to http d…
jkyberneees Oct 12, 2026
5504eb7
fix(transcribe): bound the capped runner's wait and make the alloc te…
jkyberneees Oct 12, 2026
bb91d6a
fix(speech): charge estimated usage when settling the speech budget g…
jkyberneees Oct 12, 2026
905e5ac
fix(telegram): media uploads always need an explicit per-call approval
jkyberneees Oct 12, 2026
4374311
fix(telegram): escape mid-answer MEDIA lines like any other text
jkyberneees Oct 12, 2026
c5d0ce2
fix(telegram): emit an escaped horizontal rule for separator lines
jkyberneees Oct 12, 2026
8c85c5c
fix(telegram): close a code fence left open at end of the answer
jkyberneees Oct 12, 2026
5d9050a
fix(telegram): fallback endpoints get a fresh copy of the request body
jkyberneees Oct 12, 2026
6822535
fix(telegram): a MEDIA-prefixed answer sends the file and then the text
jkyberneees Oct 12, 2026
b40c12f
fix(telegram): media uploads honour parse mode and buttons, string fi…
jkyberneees Oct 12, 2026
03d5683
fix(telegram): deliver the task-cancelled notice (escaped, with plain…
jkyberneees Oct 12, 2026
5d38813
fix(telegram): escape parentheses in the daily-budget warning
jkyberneees Oct 12, 2026
b84f6b9
fix(telegram): send clarify questions as escaped MarkdownV2
jkyberneees Oct 12, 2026
8f35cce
fix(schedule): releasing the schedule lock clears the holder PID
jkyberneees Oct 12, 2026
6788ded
fix(schedule): display fire times in the scheduler's default timezone
jkyberneees Oct 12, 2026
fbeabb1
fix(telegram): /plan_resume injects the plan inside the untrusted wra…
jkyberneees Oct 12, 2026
02a9ced
fix(schedule): a scheduled turn recorded into a chat inherits the run…
jkyberneees Oct 12, 2026
9f462f6
docs(telegram): upload approval, MEDIA handling, plan resume and sche…
jkyberneees Oct 12, 2026
1a4d8fd
test(telegram): cover episode-taint marking, media params and open-fe…
jkyberneees Oct 12, 2026
961ba23
fix(telegram): guard the clarify prompt message ID against the answer…
jkyberneees Oct 12, 2026
62a3e6d
test(schedule): timezone view test no longer mutates time.Local
jkyberneees Oct 12, 2026
d713996
fix(telegram): split long lines without leaving entities open
jkyberneees Oct 12, 2026
c86ba3a
fix(telegram): retry only markup rejections, as stripped plain text
jkyberneees Oct 12, 2026
c5b81c7
fix(schedule): contended lock message never shows an empty PID
jkyberneees Oct 12, 2026
a594cb8
test(resource): case-fold alias of a trust anchor fails instead of sk…
jkyberneees Oct 12, 2026
b6152fb
test(danger): pin HOME in the path-classification helper
jkyberneees Oct 12, 2026
4d7e747
fix(danger): classify substitutions in arithmetic and subscript operands
jkyberneees Oct 12, 2026
a9fdbc9
fix(danger): resolve parameter default expansions in classification a…
jkyberneees Oct 12, 2026
6a0eced
fix(danger): classify jq/ps/export -p environment disclosure as syste…
jkyberneees Oct 12, 2026
fd3bb3a
fix(danger): escalate pkill/killall catch-all patterns to system_write
jkyberneees Oct 12, 2026
f462a4e
fix(danger): classify gpg program options as code_execution and key d…
jkyberneees Oct 12, 2026
4801424
fix(danger): path-classify sqlite ATTACH DATABASE targets
jkyberneees Oct 12, 2026
470f98d
fix(danger): scan trap action strings against the denylist
jkyberneees Oct 12, 2026
2677fce
fix(danger): gate unread scripts behind fused value options, BASH_ENV…
jkyberneees Oct 12, 2026
b2dba12
fix(danger): escape zero-width nonspacing marks in approval prompts
jkyberneees Oct 12, 2026
ddd030b
fix(danger): see fused printf -vNAME subscript substitutions
jkyberneees Oct 12, 2026
fa26221
fix(danger): classify awk ENVIRON, yq env, and bare readonly -p as en…
jkyberneees Oct 12, 2026
20a45a6
fix(danger): treat near-universal pkill patterns and root-owned targe…
jkyberneees Oct 12, 2026
ed437f2
fix(danger): let exact gpg option names win over abbreviation and gat…
jkyberneees Oct 12, 2026
9eaf5d9
fix(danger): classify code carried by shell env assignments and gate …
jkyberneees Oct 12, 2026
0ce016e
fix(danger): escape U+180F, braille blank and object replacement in p…
jkyberneees Oct 12, 2026
d66686d
test(danger): pin review follow-ups, drop dated helper names, set HOM…
jkyberneees Oct 12, 2026
f4effc8
docs(security): describe env-assignment code, gpg homedir and env-dum…
jkyberneees Oct 12, 2026
8f074eb
fix(resource): gate @-references on sensitivity, not system-prefix rank
jkyberneees Oct 12, 2026
2a8d8ab
fix(danger): map URL hosts through UTS-46 in ClassifyURL
jkyberneees Oct 12, 2026
52b0620
fix(redact): stop redacting working directories and test names
jkyberneees Oct 12, 2026
1e555f7
fix(schedule): resolve the default timezone once per command
jkyberneees Oct 12, 2026
81f730a
fix(redact): scope path and test-name rejection to the pwd and pass keys
jkyberneees Oct 12, 2026
2c449b4
fix(danger): root-scoped, fold-aware sensitive-path predicate
jkyberneees Oct 12, 2026
d384a0c
fix(redact): require credential shape for bare token/secret/pass values
jkyberneees Oct 12, 2026
839b14d
fix(danger): share the home credential table between ClassifyPath and…
jkyberneees Oct 12, 2026
c9e5eeb
fix(danger): classify PROMPT_COMMAND/PS/BASH_FUNC values at assignment
jkyberneees Oct 12, 2026
24ee12f
fix(danger): detect match-everything pkill patterns by sampling reali…
jkyberneees Oct 12, 2026
d2b272e
fix(danger): match key-file name hints as exact names or strong tokens
jkyberneees Oct 12, 2026
7e94fa9
fix(serve): give rejected /api/jobs polls their own rate-limit bucket
jkyberneees Oct 12, 2026
1a95314
docs: @-reference protected paths, bare-key redaction value checks, n…
jkyberneees Oct 12, 2026
6e460d4
test: pin subagent concurrency clamp and plan-resume taint at their o…
jkyberneees Oct 12, 2026
396ceb2
fix(danger): match home credential entries on path components for @-r…
jkyberneees Oct 12, 2026
bf88604
fix(danger): classify printf -v and read/mapfile assignments to code …
jkyberneees Oct 12, 2026
35aba27
fix(redact): redact unseparated alphanumeric bare-key values, keep wo…
jkyberneees Oct 12, 2026
e54fcf0
chore(lint): drop unused artifactIDList; simplify sqlite ATTACH alias…
jkyberneees Oct 12, 2026
e64b679
test: make the git-default and neutraliser-linearity checks environme…
jkyberneees Oct 12, 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
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ internal/
from tool output); effects.go + reconcile.go — effect evidence and
post-batch reconciliation; signal.go — SignalEvent observability
(context_trimmed, tool_recovery, tool_running heartbeat); budget enforcement
(budget.Checker) + odek.event/v1 emission.
(budget.Checker) + odek.event/v1 emission; neutralise_tags.go — rewrites
look-alikes of the untrusted-content boundary tag inside wrapped bodies.
tool/ Thread-safe tool registry, clarify.go, send_message.go, purity.go (first-party-only
pure-output registry: a registered type's pure calls skip the ingest taint)
danger/ Command/URL classification + bypass-resistant tokenizer. Approver interface +
Expand All @@ -129,6 +130,13 @@ internal/
readledger.go Unread-script gate: fingerprinted, bounded read ledger; ForgetReadLedger per session key
ledger_indirect.go Scripts delivered indirectly (pipes, substitutions, eval, find -exec, program-file options)
path_identity.go Symlink-resolving path targets for classification (snapshot, not an execution boundary)
sensitive_read.go IsSensitiveReadPath: paths @-references refuse (shares the home credential table with ClassifyPath)
arith_eval.go Arithmetic-evaluating builtins (let, declare, …): command substitutions in subscripts are classified
param_default.go ${name:-word} / ${name:=word} / ${name:+word} default words classified as the commands they can run
env_dump_tools.go jq/yq/ps environment disclosure → system_write
env_code.go PROMPT_COMMAND / PS0-PS4 / BASH_FUNC_ assignments: values classified as commands
gpg_adapter.go gpg classified by operation (keyring mutation, photo viewer, non-default homedir)
kill_broadcast.go pkill/killall broadcast detection (match-everything patterns, user selectors, -P 1)
injection.go Injection scanner (ScanInjection) with Unicode folding
approver.go Approver interface, TTY approver, friction
display.go SanitizeForDisplay/SanitizeInline: control/bidi/invisible characters escaped in every prompt
Expand All @@ -154,7 +162,8 @@ internal/
Config: `maintenance` section (operator-only).
skills/ Skill system (types, loader, triggers, import, cache). SkillProvenance gate.
config/ Config file loading, env vars, secrets.env, priority merge, limits clamp (project may only lower budgets)
telegram/ Telegram bot: bot.go, poller.go, handler.go, commands.go, session.go, health.go, plan.go, media_path.go
telegram/ Telegram bot: bot.go, poller.go, handler.go, commands.go, session.go, health.go, plan.go, media_path.go,
session_scope.go (session-id → chat ownership check), send_fallback.go (plain-text resend when markup is rejected)
render/ Terminal output and narrator support
narrate/ LLM-powered emoji-rich progress messages
redact/ Secret redaction (20+ patterns)
Expand Down
23 changes: 21 additions & 2 deletions cmd/odek/artifact_read_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"io"
"os"
"strings"
"sync"
"syscall"

"github.com/BackendStack21/odek"
Expand Down Expand Up @@ -172,6 +173,23 @@ func verifyArtifactWindow(path string, ref artifact.Ref, maxBytes, offset, limit
// artifactReadTool reads registered sub-agent result artifacts by id.
type artifactReadTool struct {
ctxTool
sessMu sync.Mutex
sessionID string
}

// SetSessionID binds the tool to the calling session: it resolves and lists
// only artifacts that session's delegate_tasks registered. Serve rebinds it
// between prompts, hence the lock.
func (t *artifactReadTool) SetSessionID(id string) {
t.sessMu.Lock()
t.sessionID = id
t.sessMu.Unlock()
}

func (t *artifactReadTool) getSessionID() string {
t.sessMu.Lock()
defer t.sessMu.Unlock()
return t.sessionID
}

var _ odek.Tool = (*artifactReadTool)(nil)
Expand Down Expand Up @@ -234,9 +252,10 @@ func (t *artifactReadTool) Call(args string) (string, error) {
in.Limit = artifactReadMaxLimit
}

entry, ok := lookupSubagentArtifact(in.ID)
session := t.getSessionID()
entry, ok := lookupSessionArtifact(session, in.ID)
if !ok {
return fmt.Sprintf(`{"error":"unknown artifact id %q — registered artifacts: %s"}`, in.ID, artifactIDList()), nil
return fmt.Sprintf(`{"error":"unknown artifact id %q — registered artifacts: %s"}`, in.ID, sessionArtifactIDList(session)), nil
}

// Re-verify at read time (TOCTOU): the file is re-opened with symlink
Expand Down
32 changes: 17 additions & 15 deletions cmd/odek/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
// mention. We intentionally do not scan raw tool results here; a
// resource that merely appears in a fetched page is not itself a
// divergence unless the agent acts on it.
novel := session.NovelResources(userText, actionText.String())
novel := session.NovelResources(redact.RedactSecrets(userText), redact.RedactSecrets(actionText.String()))

// Resources introduced by untrusted content itself. Even if the user
// mentioned the same resource earlier, acting on it after it appears in
Expand Down Expand Up @@ -169,7 +169,7 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
}
var untrustedResources []string
seen := make(map[string]bool)
for _, r := range session.ResourcesIn(actionText.String()) {
for _, r := range session.ResourcesIn(redact.RedactSecrets(actionText.String())) {
lr := auditKey(r)
if untrustedResSet[lr] && !seen[lr] {
seen[lr] = true
Expand All @@ -194,9 +194,12 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
}

// 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
// content's own source s (case-insensitive): equal, or r lies under s on a
// path boundary: r continues s with '/', '?' or '#', or s ends with '/'.
// The match is one-directional: an ancestor of the source (the directory a
// file lives in, the site root of a page) is not the content's own source,
// so the content naming it is a reused-resource signal. A raw string prefix
// is not enough either: 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 {
Expand All @@ -207,17 +210,13 @@ func auditOpsResourceMatchesSource(r, s string) bool {
if lr == ls {
return true
}
short, long := ls, lr
if len(lr) < len(ls) {
short, long = lr, ls
}
if !strings.HasPrefix(long, short) {
if !strings.HasPrefix(lr, ls) {
return false
}
if strings.HasSuffix(short, "/") {
if strings.HasSuffix(ls, "/") {
return true
}
switch long[len(short)] {
switch lr[len(ls)] {
case '/', '?', '#':
return true
}
Expand Down Expand Up @@ -275,12 +274,15 @@ func auditList(store *session.Store, auditStore *session.AuditStore) error {
suspicious++
}
}
firstSource := log.Ingests[0].Source
// Ingest sources are tool-chosen labels built from attacker-
// influenced text: escape control/bidi characters first, then
// truncate on rune boundaries.
firstSource := []rune(danger.SanitizeInline(log.Ingests[0].Source))
if len(firstSource) > 40 {
firstSource = firstSource[:37] + "..."
firstSource = append(firstSource[:37], '.', '.', '.')
}
fmt.Printf("%-22s %7d %6d %11d %s\n",
s.ID, len(log.Ingests), len(log.Turns), suspicious, firstSource)
danger.SanitizeInline(s.ID), len(log.Ingests), len(log.Turns), suspicious, string(firstSource))
}
return nil
}
Expand Down
5 changes: 3 additions & 2 deletions cmd/odek/bg_telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/BackendStack21/odek/internal/bgproc"
"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/redact"
"github.com/BackendStack21/odek/internal/telegram"
)

Expand Down Expand Up @@ -265,9 +266,9 @@ func formatBGJobsForChat(rt *bgRuntime) string {
b.WriteString("📋 Background jobs:\n")
for _, j := range jobs {
if j.Status == bgproc.StatusRunning {
fmt.Fprintf(&b, "• %s — %s — running (%.0fs)\n", j.ID, headString(j.Command, 48), jobRuntimeSeconds(j))
fmt.Fprintf(&b, "• %s — %s — running (%.0fs)\n", j.ID, headString(redact.RedactSecrets(j.Command), 48), jobRuntimeSeconds(j))
} else {
fmt.Fprintf(&b, "• %s — %s — %s (exit %d)\n", j.ID, headString(j.Command, 48), j.Status, j.ExitCode)
fmt.Fprintf(&b, "• %s — %s — %s (exit %d)\n", j.ID, headString(redact.RedactSecrets(j.Command), 48), j.Status, j.ExitCode)
}
}
return strings.TrimRight(b.String(), "\n")
Expand Down
25 changes: 18 additions & 7 deletions cmd/odek/bg_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ func formatOneNotice(n bgproc.Notice) string {
var line string
switch n.Status {
case bgproc.StatusExited:
line = fmt.Sprintf("%s `%s` → exited (code %d) after %s", n.JobID, headString(n.Command, bgCommandHead), n.ExitCode, dur)
line = fmt.Sprintf("%s `%s` → exited (code %d) after %s", n.JobID, bgClientCommandHead(n.Command), n.ExitCode, dur)
case bgproc.StatusFailed:
line = fmt.Sprintf("%s `%s` → failed (exit %d) after %s", n.JobID, headString(n.Command, bgCommandHead), n.ExitCode, dur)
line = fmt.Sprintf("%s `%s` → failed (exit %d) after %s", n.JobID, bgClientCommandHead(n.Command), n.ExitCode, dur)
case bgproc.StatusTimeout:
line = fmt.Sprintf("%s `%s` → timeout after %s", n.JobID, headString(n.Command, bgCommandHead), dur)
line = fmt.Sprintf("%s `%s` → timeout after %s", n.JobID, bgClientCommandHead(n.Command), dur)
default:
line = fmt.Sprintf("%s `%s` → %s after %s", n.JobID, headString(n.Command, bgCommandHead), n.Status, dur)
line = fmt.Sprintf("%s `%s` → %s after %s", n.JobID, bgClientCommandHead(n.Command), n.Status, dur)
}
if n.Tail != "" {
line += "\n tail: " + redact.RedactSecrets(n.Tail)
Expand Down Expand Up @@ -360,6 +360,17 @@ func (t *bgStartTool) Schema() any {
const maxBGTimeoutSeconds = 365 * 24 * 3600

func (t *bgStartTool) Call(args string) (string, error) {
return t.CallContext(context.Background(), args)
}

// CallContext evaluates the approval gate with the invocation context scoped
// to this runtime's session, so the unread-script gate consults the session's
// read ledger (as the shell tool does) rather than whatever context the shell
// tool last recorded.
func (t *bgStartTool) CallContext(ctx context.Context, args string) (string, error) {
if t.rt != nil {
ctx = withReadLedger(ctx, t.rt.session)
}
var p struct {
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
Expand All @@ -385,9 +396,9 @@ func (t *bgStartTool) Call(args string) (string, error) {
if t.shell == nil {
return "", fmt.Errorf("bg_start unavailable: approval gate not wired")
}
approvedRisk, _ := danger.ClassifyScriptGateCtx(t.shell.toolCtx(), p.Command)
approvedRisk, unreadTargets := shellScriptGate(ctx, p.Command)
approvedEffects := danger.Analyze(p.Command).Effects
if err := t.shell.checkApproval(p.Command, "background job"); err != nil {
if err := t.shell.checkApprovalGated(p.Command, "background job", unreadTargets); err != nil {
return "", err
}
opts := bgproc.SpawnOptions{}
Expand All @@ -398,7 +409,7 @@ func (t *bgStartTool) Call(args string) (string, error) {
return "", err
}
}
if err := revalidateShellRisk(t.shell.toolCtx(), p.Command, approvedRisk, approvedEffects); err != nil {
if err := revalidateShellRisk(ctx, p.Command, approvedRisk, approvedEffects); err != nil {
if opts.Release != nil {
opts.Release()
}
Expand Down
28 changes: 22 additions & 6 deletions cmd/odek/browser_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ const maxBrowserElements = 500
// history limit cannot be bypassed by a small number of huge pages.
const maxBrowserSnapshotBytes = 1 * 1024 * 1024

// Per-field caps for page-derived strings kept outside Content (title, element
// text, link URLs). With maxBrowserElements they bound the whole snapshot.
const (
maxBrowserFieldBytes = 2 * 1024
maxBrowserURLBytes = 8 * 1024
)

// capBrowserField clamps a single page-derived string to maxBrowserFieldBytes.
func capBrowserField(s string) string {
if len(s) <= maxBrowserFieldBytes {
return s
}
return truncateUTF8Safe(s, maxBrowserFieldBytes)
}

// truncatePageContent caps page text at maxBrowserSnapshotBytes, backing up
// to a UTF-8 rune boundary so a multibyte character split by the cap never
// ships U+FFFD mojibake, and appends a truncation marker.
Expand Down Expand Up @@ -117,7 +132,7 @@ func (t *browserTool) checkRedirect(req *http.Request, via []*http.Request) erro
return fmt.Errorf("stopped after 10 redirects")
}
target := req.URL.String()
risk := danger.ClassifyURL(target)
risk := ssrfClassifyURL(target)
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
Name: "browser", Resource: target, Risk: risk,
}, t.trustedClasses); err != nil {
Expand Down Expand Up @@ -229,7 +244,7 @@ func (t *browserTool) doNavigate(rawURL string) (string, error) {
req.Header.Set("Accept", "text/html,application/xhtml+xml")

// Security: classify and check browser operation
risk := danger.ClassifyURL(rawURL)
risk := ssrfClassifyURL(rawURL)
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
Name: "browser", Resource: rawURL, Risk: risk,
}, t.trustedClasses); err != nil {
Expand Down Expand Up @@ -378,7 +393,7 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna

// Extract title
if m := reTitle.FindStringSubmatch(html); len(m) > 1 {
snap.Title = strings.TrimSpace(m[1])
snap.Title = capBrowserField(strings.TrimSpace(m[1]))
}

var contentParts []string
Expand Down Expand Up @@ -416,12 +431,12 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
break
}
href := strings.TrimSpace(m[1])
text := strings.TrimSpace(m[2])
text := capBrowserField(strings.TrimSpace(m[2]))
// Skip non-navigable schemes. The check is case-insensitive and
// covers javascript:, data:, and vbscript: — all of which can carry
// script/active content the agent must never be sent into.
lowerHref := strings.ToLower(href)
if href == "" || text == "" || href == "#" ||
if href == "" || text == "" || href == "#" || len(href) > maxBrowserURLBytes ||
strings.HasPrefix(lowerHref, "javascript:") ||
strings.HasPrefix(lowerHref, "data:") ||
strings.HasPrefix(lowerHref, "vbscript:") {
Expand Down Expand Up @@ -451,7 +466,7 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
if len(elements) >= maxBrowserElements {
break
}
text := strings.TrimSpace(m[1])
text := capBrowserField(strings.TrimSpace(m[1]))
if text == "" {
text = "button"
}
Expand All @@ -476,6 +491,7 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
} else if pm := reInputPlaceholder.FindStringSubmatch(tag); len(pm) > 1 {
text = "[" + pm[1] + "]"
}
text = capBrowserField(text)
if text == "" {
text = "input"
}
Expand Down
24 changes: 19 additions & 5 deletions cmd/odek/file_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,7 @@ func (t *searchFilesTool) searchContent(args searchFilesArgs) (string, error) {
}

t.wrapContentMatches(matches)
return jsonResult(searchFilesResult{Matches: matches, Skipped: skipped})
return jsonResult(searchFilesResult{Matches: matches, Skipped: t.wrapSkipped(skipped)})
}

// wrapContentMatches marks every matched path and line as untrusted. All
Expand Down Expand Up @@ -1043,7 +1043,20 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
}
}

return jsonResult(searchFilesResult{Matches: matches, Skipped: skipped})
return jsonResult(searchFilesResult{Matches: matches, Skipped: t.wrapSkipped(skipped)})
}

// wrapSkipped marks skipped entries as untrusted: they carry repository file
// names and error text, which are as attacker-controlled as matched paths.
func (t *searchFilesTool) wrapSkipped(skipped []string) []string {
if len(skipped) == 0 {
return skipped
}
sources := make([]string, len(skipped))
for i, s := range skipped {
sources[i] = "search_files:skipped:" + s
}
return wrapUntrustedBatch(t.toolCtx(), "search_files:skipped", sources, skipped)
}

// ── Patch Tool ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1590,8 +1603,9 @@ func confineToCWD(path string) (string, error) {
}
for _, h := range homes {
odekPrefix := h + "/.odek/"
if len(absResolved) >= len(odekPrefix) && strings.EqualFold(absResolved[:len(odekPrefix)], odekPrefix) {
if isProtectedOdekPath(absResolved[len(odekPrefix):]) {
foldedAbs, foldedPrefix := danger.FoldPathCase(absResolved), danger.FoldPathCase(odekPrefix)
if strings.HasPrefix(foldedAbs, foldedPrefix) {
if isProtectedOdekPath(foldedAbs[len(foldedPrefix):]) {
return "", fmt.Errorf("path %q is a protected odek configuration path and cannot be written by file tools", path)
}
return abs, nil
Expand All @@ -1614,7 +1628,7 @@ func confineToCWD(path string) (string, error) {
func isProtectedOdekPath(rel string) bool {
// Case-folding defends against case-insensitive filesystems (macOS APFS):
// ~/.odek/CONFIG.JSON and ~/.odek/config.json are the same file.
rel = strings.ToLower(filepath.Clean(rel))
rel = danger.FoldPathCase(filepath.Clean(rel))

// Exact-file trust anchors. Rewriting any of these can disable safety
// policy, exfiltrate secrets, or persist attacker control.
Expand Down
Loading
Loading