Skip to content

Commit 3df53de

Browse files
jkyberneeesclaude
andauthored
fix: 80 validated findings from the 2026-10-12 bug hunt (#317)
Fixes 80 validated findings (18 high, 42 medium, 20 low) from the 2026-10-12 functional and security bug hunt across danger, loop, memory, telegram, serve, tools, config, runtime and ops, plus follow-ups from per-area and two full-branch adversarial reviews. See PR #317 for details. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ce0bbfe commit 3df53de

146 files changed

Lines changed: 8578 additions & 413 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ internal/
107107
from tool output); effects.go + reconcile.go — effect evidence and
108108
post-batch reconciliation; signal.go — SignalEvent observability
109109
(context_trimmed, tool_recovery, tool_running heartbeat); budget enforcement
110-
(budget.Checker) + odek.event/v1 emission.
110+
(budget.Checker) + odek.event/v1 emission; neutralise_tags.go — rewrites
111+
look-alikes of the untrusted-content boundary tag inside wrapped bodies.
111112
tool/ Thread-safe tool registry, clarify.go, send_message.go, purity.go (first-party-only
112113
pure-output registry: a registered type's pure calls skip the ingest taint)
113114
danger/ Command/URL classification + bypass-resistant tokenizer. Approver interface +
@@ -129,6 +130,13 @@ internal/
129130
readledger.go Unread-script gate: fingerprinted, bounded read ledger; ForgetReadLedger per session key
130131
ledger_indirect.go Scripts delivered indirectly (pipes, substitutions, eval, find -exec, program-file options)
131132
path_identity.go Symlink-resolving path targets for classification (snapshot, not an execution boundary)
133+
sensitive_read.go IsSensitiveReadPath: paths @-references refuse (shares the home credential table with ClassifyPath)
134+
arith_eval.go Arithmetic-evaluating builtins (let, declare, …): command substitutions in subscripts are classified
135+
param_default.go ${name:-word} / ${name:=word} / ${name:+word} default words classified as the commands they can run
136+
env_dump_tools.go jq/yq/ps environment disclosure → system_write
137+
env_code.go PROMPT_COMMAND / PS0-PS4 / BASH_FUNC_ assignments: values classified as commands
138+
gpg_adapter.go gpg classified by operation (keyring mutation, photo viewer, non-default homedir)
139+
kill_broadcast.go pkill/killall broadcast detection (match-everything patterns, user selectors, -P 1)
132140
injection.go Injection scanner (ScanInjection) with Unicode folding
133141
approver.go Approver interface, TTY approver, friction
134142
display.go SanitizeForDisplay/SanitizeInline: control/bidi/invisible characters escaped in every prompt
@@ -154,7 +162,8 @@ internal/
154162
Config: `maintenance` section (operator-only).
155163
skills/ Skill system (types, loader, triggers, import, cache). SkillProvenance gate.
156164
config/ Config file loading, env vars, secrets.env, priority merge, limits clamp (project may only lower budgets)
157-
telegram/ Telegram bot: bot.go, poller.go, handler.go, commands.go, session.go, health.go, plan.go, media_path.go
165+
telegram/ Telegram bot: bot.go, poller.go, handler.go, commands.go, session.go, health.go, plan.go, media_path.go,
166+
session_scope.go (session-id → chat ownership check), send_fallback.go (plain-text resend when markup is rejected)
158167
render/ Terminal output and narrator support
159168
narrate/ LLM-powered emoji-rich progress messages
160169
redact/ Secret redaction (20+ patterns)

‎cmd/odek/artifact_read_tool.go‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"io"
2727
"os"
2828
"strings"
29+
"sync"
2930
"syscall"
3031

3132
"github.com/BackendStack21/odek"
@@ -172,6 +173,23 @@ func verifyArtifactWindow(path string, ref artifact.Ref, maxBytes, offset, limit
172173
// artifactReadTool reads registered sub-agent result artifacts by id.
173174
type artifactReadTool struct {
174175
ctxTool
176+
sessMu sync.Mutex
177+
sessionID string
178+
}
179+
180+
// SetSessionID binds the tool to the calling session: it resolves and lists
181+
// only artifacts that session's delegate_tasks registered. Serve rebinds it
182+
// between prompts, hence the lock.
183+
func (t *artifactReadTool) SetSessionID(id string) {
184+
t.sessMu.Lock()
185+
t.sessionID = id
186+
t.sessMu.Unlock()
187+
}
188+
189+
func (t *artifactReadTool) getSessionID() string {
190+
t.sessMu.Lock()
191+
defer t.sessMu.Unlock()
192+
return t.sessionID
175193
}
176194

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

237-
entry, ok := lookupSubagentArtifact(in.ID)
255+
session := t.getSessionID()
256+
entry, ok := lookupSessionArtifact(session, in.ID)
238257
if !ok {
239-
return fmt.Sprintf(`{"error":"unknown artifact id %q — registered artifacts: %s"}`, in.ID, artifactIDList()), nil
258+
return fmt.Sprintf(`{"error":"unknown artifact id %q — registered artifacts: %s"}`, in.ID, sessionArtifactIDList(session)), nil
240259
}
241260

242261
// Re-verify at read time (TOCTOU): the file is re-opened with symlink

‎cmd/odek/audit.go‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, user
113113
// mention. We intentionally do not scan raw tool results here; a
114114
// resource that merely appears in a fetched page is not itself a
115115
// divergence unless the agent acts on it.
116-
novel := session.NovelResources(userText, actionText.String())
116+
novel := session.NovelResources(redact.RedactSecrets(userText), redact.RedactSecrets(actionText.String()))
117117

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

196196
// auditOpsResourceMatchesSource reports whether resource r is the untrusted
197-
// content's own source s (case-insensitive): equal, or one lies under the
198-
// other on a path boundary — the longer continues with '/', '?' or '#', or
199-
// the shorter ends with '/'. A raw string prefix is not enough: the
197+
// content's own source s (case-insensitive): equal, or r lies under s on a
198+
// path boundary: r continues s with '/', '?' or '#', or s ends with '/'.
199+
// The match is one-directional: an ancestor of the source (the directory a
200+
// file lives in, the site root of a page) is not the content's own source,
201+
// so the content naming it is a reused-resource signal. A raw string prefix
202+
// is not enough either: the
200203
// look-alike host https://news.example.com.collector.net extends the source
201204
// https://news.example.com without being part of it.
202205
func auditOpsResourceMatchesSource(r, s string) bool {
@@ -207,17 +210,13 @@ func auditOpsResourceMatchesSource(r, s string) bool {
207210
if lr == ls {
208211
return true
209212
}
210-
short, long := ls, lr
211-
if len(lr) < len(ls) {
212-
short, long = lr, ls
213-
}
214-
if !strings.HasPrefix(long, short) {
213+
if !strings.HasPrefix(lr, ls) {
215214
return false
216215
}
217-
if strings.HasSuffix(short, "/") {
216+
if strings.HasSuffix(ls, "/") {
218217
return true
219218
}
220-
switch long[len(short)] {
219+
switch lr[len(ls)] {
221220
case '/', '?', '#':
222221
return true
223222
}
@@ -275,12 +274,15 @@ func auditList(store *session.Store, auditStore *session.AuditStore) error {
275274
suspicious++
276275
}
277276
}
278-
firstSource := log.Ingests[0].Source
277+
// Ingest sources are tool-chosen labels built from attacker-
278+
// influenced text: escape control/bidi characters first, then
279+
// truncate on rune boundaries.
280+
firstSource := []rune(danger.SanitizeInline(log.Ingests[0].Source))
279281
if len(firstSource) > 40 {
280-
firstSource = firstSource[:37] + "..."
282+
firstSource = append(firstSource[:37], '.', '.', '.')
281283
}
282284
fmt.Printf("%-22s %7d %6d %11d %s\n",
283-
s.ID, len(log.Ingests), len(log.Turns), suspicious, firstSource)
285+
danger.SanitizeInline(s.ID), len(log.Ingests), len(log.Turns), suspicious, string(firstSource))
284286
}
285287
return nil
286288
}

‎cmd/odek/bg_telegram.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/BackendStack21/odek/internal/bgproc"
1111
"github.com/BackendStack21/odek/internal/config"
12+
"github.com/BackendStack21/odek/internal/redact"
1213
"github.com/BackendStack21/odek/internal/telegram"
1314
)
1415

@@ -265,9 +266,9 @@ func formatBGJobsForChat(rt *bgRuntime) string {
265266
b.WriteString("📋 Background jobs:\n")
266267
for _, j := range jobs {
267268
if j.Status == bgproc.StatusRunning {
268-
fmt.Fprintf(&b, "• %s — %s — running (%.0fs)\n", j.ID, headString(j.Command, 48), jobRuntimeSeconds(j))
269+
fmt.Fprintf(&b, "• %s — %s — running (%.0fs)\n", j.ID, headString(redact.RedactSecrets(j.Command), 48), jobRuntimeSeconds(j))
269270
} else {
270-
fmt.Fprintf(&b, "• %s — %s — %s (exit %d)\n", j.ID, headString(j.Command, 48), j.Status, j.ExitCode)
271+
fmt.Fprintf(&b, "• %s — %s — %s (exit %d)\n", j.ID, headString(redact.RedactSecrets(j.Command), 48), j.Status, j.ExitCode)
271272
}
272273
}
273274
return strings.TrimRight(b.String(), "\n")

‎cmd/odek/bg_tools.go‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,13 @@ func formatOneNotice(n bgproc.Notice) string {
203203
var line string
204204
switch n.Status {
205205
case bgproc.StatusExited:
206-
line = fmt.Sprintf("%s `%s` → exited (code %d) after %s", n.JobID, headString(n.Command, bgCommandHead), n.ExitCode, dur)
206+
line = fmt.Sprintf("%s `%s` → exited (code %d) after %s", n.JobID, bgClientCommandHead(n.Command), n.ExitCode, dur)
207207
case bgproc.StatusFailed:
208-
line = fmt.Sprintf("%s `%s` → failed (exit %d) after %s", n.JobID, headString(n.Command, bgCommandHead), n.ExitCode, dur)
208+
line = fmt.Sprintf("%s `%s` → failed (exit %d) after %s", n.JobID, bgClientCommandHead(n.Command), n.ExitCode, dur)
209209
case bgproc.StatusTimeout:
210-
line = fmt.Sprintf("%s `%s` → timeout after %s", n.JobID, headString(n.Command, bgCommandHead), dur)
210+
line = fmt.Sprintf("%s `%s` → timeout after %s", n.JobID, bgClientCommandHead(n.Command), dur)
211211
default:
212-
line = fmt.Sprintf("%s `%s` → %s after %s", n.JobID, headString(n.Command, bgCommandHead), n.Status, dur)
212+
line = fmt.Sprintf("%s `%s` → %s after %s", n.JobID, bgClientCommandHead(n.Command), n.Status, dur)
213213
}
214214
if n.Tail != "" {
215215
line += "\n tail: " + redact.RedactSecrets(n.Tail)
@@ -360,6 +360,17 @@ func (t *bgStartTool) Schema() any {
360360
const maxBGTimeoutSeconds = 365 * 24 * 3600
361361

362362
func (t *bgStartTool) Call(args string) (string, error) {
363+
return t.CallContext(context.Background(), args)
364+
}
365+
366+
// CallContext evaluates the approval gate with the invocation context scoped
367+
// to this runtime's session, so the unread-script gate consults the session's
368+
// read ledger (as the shell tool does) rather than whatever context the shell
369+
// tool last recorded.
370+
func (t *bgStartTool) CallContext(ctx context.Context, args string) (string, error) {
371+
if t.rt != nil {
372+
ctx = withReadLedger(ctx, t.rt.session)
373+
}
363374
var p struct {
364375
Command string `json:"command"`
365376
TimeoutSeconds int `json:"timeout_seconds"`
@@ -385,9 +396,9 @@ func (t *bgStartTool) Call(args string) (string, error) {
385396
if t.shell == nil {
386397
return "", fmt.Errorf("bg_start unavailable: approval gate not wired")
387398
}
388-
approvedRisk, _ := danger.ClassifyScriptGateCtx(t.shell.toolCtx(), p.Command)
399+
approvedRisk, unreadTargets := shellScriptGate(ctx, p.Command)
389400
approvedEffects := danger.Analyze(p.Command).Effects
390-
if err := t.shell.checkApproval(p.Command, "background job"); err != nil {
401+
if err := t.shell.checkApprovalGated(p.Command, "background job", unreadTargets); err != nil {
391402
return "", err
392403
}
393404
opts := bgproc.SpawnOptions{}
@@ -398,7 +409,7 @@ func (t *bgStartTool) Call(args string) (string, error) {
398409
return "", err
399410
}
400411
}
401-
if err := revalidateShellRisk(t.shell.toolCtx(), p.Command, approvedRisk, approvedEffects); err != nil {
412+
if err := revalidateShellRisk(ctx, p.Command, approvedRisk, approvedEffects); err != nil {
402413
if opts.Release != nil {
403414
opts.Release()
404415
}

‎cmd/odek/browser_tool.go‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ const maxBrowserElements = 500
6060
// history limit cannot be bypassed by a small number of huge pages.
6161
const maxBrowserSnapshotBytes = 1 * 1024 * 1024
6262

63+
// Per-field caps for page-derived strings kept outside Content (title, element
64+
// text, link URLs). With maxBrowserElements they bound the whole snapshot.
65+
const (
66+
maxBrowserFieldBytes = 2 * 1024
67+
maxBrowserURLBytes = 8 * 1024
68+
)
69+
70+
// capBrowserField clamps a single page-derived string to maxBrowserFieldBytes.
71+
func capBrowserField(s string) string {
72+
if len(s) <= maxBrowserFieldBytes {
73+
return s
74+
}
75+
return truncateUTF8Safe(s, maxBrowserFieldBytes)
76+
}
77+
6378
// truncatePageContent caps page text at maxBrowserSnapshotBytes, backing up
6479
// to a UTF-8 rune boundary so a multibyte character split by the cap never
6580
// ships U+FFFD mojibake, and appends a truncation marker.
@@ -117,7 +132,7 @@ func (t *browserTool) checkRedirect(req *http.Request, via []*http.Request) erro
117132
return fmt.Errorf("stopped after 10 redirects")
118133
}
119134
target := req.URL.String()
120-
risk := danger.ClassifyURL(target)
135+
risk := ssrfClassifyURL(target)
121136
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
122137
Name: "browser", Resource: target, Risk: risk,
123138
}, t.trustedClasses); err != nil {
@@ -229,7 +244,7 @@ func (t *browserTool) doNavigate(rawURL string) (string, error) {
229244
req.Header.Set("Accept", "text/html,application/xhtml+xml")
230245

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

379394
// Extract title
380395
if m := reTitle.FindStringSubmatch(html); len(m) > 1 {
381-
snap.Title = strings.TrimSpace(m[1])
396+
snap.Title = capBrowserField(strings.TrimSpace(m[1]))
382397
}
383398

384399
var contentParts []string
@@ -416,12 +431,12 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
416431
break
417432
}
418433
href := strings.TrimSpace(m[1])
419-
text := strings.TrimSpace(m[2])
434+
text := capBrowserField(strings.TrimSpace(m[2]))
420435
// Skip non-navigable schemes. The check is case-insensitive and
421436
// covers javascript:, data:, and vbscript: — all of which can carry
422437
// script/active content the agent must never be sent into.
423438
lowerHref := strings.ToLower(href)
424-
if href == "" || text == "" || href == "#" ||
439+
if href == "" || text == "" || href == "#" || len(href) > maxBrowserURLBytes ||
425440
strings.HasPrefix(lowerHref, "javascript:") ||
426441
strings.HasPrefix(lowerHref, "data:") ||
427442
strings.HasPrefix(lowerHref, "vbscript:") {
@@ -451,7 +466,7 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
451466
if len(elements) >= maxBrowserElements {
452467
break
453468
}
454-
text := strings.TrimSpace(m[1])
469+
text := capBrowserField(strings.TrimSpace(m[1]))
455470
if text == "" {
456471
text = "button"
457472
}
@@ -476,6 +491,7 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
476491
} else if pm := reInputPlaceholder.FindStringSubmatch(tag); len(pm) > 1 {
477492
text = "[" + pm[1] + "]"
478493
}
494+
text = capBrowserField(text)
479495
if text == "" {
480496
text = "input"
481497
}

‎cmd/odek/file_tool.go‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,7 @@ func (t *searchFilesTool) searchContent(args searchFilesArgs) (string, error) {
982982
}
983983

984984
t.wrapContentMatches(matches)
985-
return jsonResult(searchFilesResult{Matches: matches, Skipped: skipped})
985+
return jsonResult(searchFilesResult{Matches: matches, Skipped: t.wrapSkipped(skipped)})
986986
}
987987

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

1046-
return jsonResult(searchFilesResult{Matches: matches, Skipped: skipped})
1046+
return jsonResult(searchFilesResult{Matches: matches, Skipped: t.wrapSkipped(skipped)})
1047+
}
1048+
1049+
// wrapSkipped marks skipped entries as untrusted: they carry repository file
1050+
// names and error text, which are as attacker-controlled as matched paths.
1051+
func (t *searchFilesTool) wrapSkipped(skipped []string) []string {
1052+
if len(skipped) == 0 {
1053+
return skipped
1054+
}
1055+
sources := make([]string, len(skipped))
1056+
for i, s := range skipped {
1057+
sources[i] = "search_files:skipped:" + s
1058+
}
1059+
return wrapUntrustedBatch(t.toolCtx(), "search_files:skipped", sources, skipped)
10471060
}
10481061

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

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

0 commit comments

Comments
 (0)