Skip to content

Commit e01f95f

Browse files
authored
fix: module-by-module bug sweep - security, correctness, and reliability fixes (#168)
* fix(danger): classify trap payloads and bun -e as code exec; structural fork-bomb blocking trap was in safeCommands so trap-with-payload auto-allowed code execution; bun was missing from codeEvalPrefixes so bun -e fell through to the Safe install fallback; isRawBlocked denied any command containing both brace-colon substrings, blocking innocent echo patterns even in godmode. Trap query forms stay safe. Found in the 2026-08-31 expert bug sweep; RED-first tests in classifier_bugfix_test.go. * fix(budget): enforce input and cost caps against cache tokens Cache reads and writes are real prompt tokens with real cost, but CheckUsage never saw them: on Anthropic a fully cached prompt counted as zero input, so max_input_tokens and max_cost_usd never tripped. llm now normalizes usage to exclusive semantics on every provider (OpenAI cached_tokens and DeepSeek hit/miss are subsets of prompt_tokens; Anthropic was exclusive already), budget gains Checker.CheckUsageWithCache summing input plus cache volumes, and both loop call sites switch to it. TotalInputTokens is now uncached-only for display. Found in the 2026-08-31 expert bug sweep; RED-first tests in budget_cache_test.go and usage_cache_test.go. * fix(skills): pin imported skills NeedsReview; refuse private hosts on import fetch ImportSkill set only Quality, AutoLoad and LastUsed, so a URI-imported skill was trigger-matchable immediately with triggers derived from the attacker-controlled body; imports now pin Provenance.NeedsReview after parsing so remote frontmatter cannot clear it, keeping odek skill promote as the review gate. fetchHTTP also checked isPrivateHost only on redirects, leaving the initial fetch an open SSRF to link-local metadata addresses; private and internal hosts are now refused up front, with fetchHTTPAllow as the explicit opt-out for callers that own the target choice. Found in the 2026-08-31 expert bug sweep; RED-first tests in importer_review_test.go. * fix(loop): never drop the original task when a leading injection shifted the trim boundary noteLeadingInjection sets ctxLeadDroppableFrom inside the leading system run, and headLen then stops before the first user message, so pass 2 of trimContext dropped the original task as the first standalone group, violating the documented protected-head invariant. Pass 2 now locates the task, keeps it protected while older groups drop, and ends prefix dropping when the scan reaches it; injected blocks remain droppable ahead of the task, which stays the point of the boundary. Found in the 2026-08-31 expert bug sweep; RED-first tests in trim_task_test.go. * fix(shell): surface exit status on failing commands that produced output A failing command with captured output returned the output with a nil error, silently dropping the exit status, so a failing test or build run was indistinguishable from a passing one (parallel_shell already reports exit_code per command). Output is still returned since the model needs stdout and stderr, but the failure is now named as [command failed: exit status N]; silent failures keep the error return. Found in the 2026-08-31 expert bug sweep; RED-first tests in shell_exit_status_test.go. * fix(artifacts): close TOCTOU on artifact read - verify identity of what is actually served artifact_read re-opened the registered artifact by path at read time with only an os.Stat that follows symlinks, and never re-checked the recorded sha256, so a same-user process could swap the artifact file for a symlink after collation and have arbitrary readable files streamed into the parent context. Reads now Lstat the final component, open with O_NOFOLLOW, stat the open handle (regular, within cap, size matching the ref), and stream sha256 over every byte served, failing closed on any mismatch. renderArtifacts inline previews go through the same gate. * fix(danger): env equals-form flags are env manipulation; TrustedClasses write under mutex; case-folded home guard; honest friction count env --unset=X and other equals-form long options hid a flag-only environment dump behind the wrapper-stripping layer and degraded to Safe; unwrapped TrustedClasses assignment raced parallel readers flagged by -race; shellPathIsHomeSensitive skipped the case-folding its peers apply, letting uppercase path variants slip past on case-insensitive filesystems; the friction warning printed the threshold constant instead of the real same-class approval count. * fix(tools): honor newest-first glob contract, UTF-8-aware binary detection, tree disclosure, honest batch_patch wording glob and search_files files-mode truncated to limit in lexical walk order before the mtime sort, hiding the newest files behind the first N lexically-named matches; collection now walks to a 20000-entry cap, sorts by mtime descending, then truncates. isBinary counted every byte over 0x7F as non-printable and rejected Cyrillic and CJK prose as binary; detection now uses NUL, control-char ratio and UTF-8 validity on an 8KB sample. tree skipped no paths, disclosing ~/.odek names the search tools deliberately skip; it now applies the same classification skip. batch_patch description no longer claims atomic all-or-nothing semantics it does not have. * fix(loop): real tool outcome for failure recovery; digest survives trims after leading injection Failure classification sniffed output text for the literal error-key substring, counting successful read or grep results that legitimately contain it as failures and firing false keep-failing hints and tool_recovery signals after three of them; classification now uses the errored flag recorded at execution (call errors, panics, missing tools, denied batches). refreshDigest inserted the compaction digest at headLen without shifting the droppable boundary, so the next trim dropped the fresh digest while the trim warning kept advertising it; insertion now shifts the boundary past itself like the plan and memory slots. * fix(serve): tracked run goroutines at shutdown, WS slot leak, pong config race, XFF keying REST run goroutines were untracked so a blocking run outlived listener shutdown with cleanup defers never running - both WS-handler and run goroutines now drain under a bounded wait. The WS connection slot was acquired in the library handshake callback but leaked when the upgrade failed after acquisition, wedging /ws after repeated failed upgrades - a wrapper now releases exactly when the handshake acquired but the handler never runs. The reader-goroutine pong read the live model field while the processor wrote it - hello and pong use an immutable server snapshot taken before the reader starts. With trusted proxies configured the rate-limit key was the client-controlled first XFF entry - now the right-most entry, and empty keys skip limiting instead of inserting a never-evicted shared bucket. * fix(skills): skill_load refuses NeedsReview bodies; harden skill-name validation The NeedsReview promotion gate only blocked trigger matching - skill_load happily served tainted skill bodies on demand, defeating the gate; the load tool now refuses pinned skills with a promote pointer, and descriptions document the withholding. ValidateSkillName rejected neither control characters nor irregular whitespace, and MarshalSkill wrote the name unquoted so YAML-significant names could inject frontmatter on reload; validation now rejects control chars, irregular whitespace and leading YAML-special characters. Docs synced where the skill_load contract is stated. * fix(budget): exhausted parent budget is a hard cap, not unlimited, in share-mode handoff Snapshot emitted Remaining=0 both for an unconfigured limit and for an exhausted one, and the sub-agent clamp applied only positive values, so a parent whose runtime or cost budget expired mid-batch spawned children with no cap at all. Snapshot now carries explicit exhausted flags, clampLimits clamps exhausted dimensions to zero, and both the parent-side spawn gate and the child-side fail-fast reject with a typed budget error mapped to exit code 4; unconfigured limits remain unlimited and the wire fields are optional so old peers keep their behavior. * fix(artifacts,mcpclient): enforce max_result_chars on rendered output, block metadata forgery, bound hash reads Render truncated nothing and the envelope branch capped only the text field, so unbounded id/mediaType/summary fields could land in model context at dozens of times the configured max_result_chars - rendering is now capped as a whole with the structured truncation notice and fields are defensively bounded. A tool-result text line beginning with the metadata bullet fabricated extra artifact entries and inflated artifact_count telemetry; continuation lines are now visually distinguished so they cannot parse as metadata. Validate hashed via unbounded io.Copy after the stat, letting a growth race defeat the 64MiB ceiling - the hash read is now limit-bounded and rejects on excess. * fix(config): secrets.env parser honors dotenv conveniences and stops silent truncation The highest-priority config layer stored export-prefixed keys as literal names, kept surrounding quotes and whitespace-preceded inline comments in values, and never checked scanner.Err, so a single overlong line silently dropped every secret after it and looked like a working configuration with keys missing. The parser now strips the export prefix, exactly one pair of surrounding quotes, and whitespace-preceded comments on bare values (quoted values keep embedded hashes), raises the token buffer to 1MiB, and surfaces scanner errors as warnings. * fix(danger,tools,config): unread-exec gate expansions, rune-safe truncation, open-first fingerprint, env warnings Dollar-prefixed script operands bypassed the unread-exec gate because the skip treated them as variable refs - expansion and stat now decide, freshness is checked against the expanded path that the read ledger actually records, and interpreter-stage operands gate even without a shebang (ENOEXEC fallback). The shell 1MiB cap, the batch_patch preview and the untrusted-content scan window cut multibyte characters mid-rune - all trim back to rune boundaries now (the batch_patch preview helper is truncatePreviewLine, distinct from the patch diff first-line helper). fingerprintFile stat-then-reopen left a swap window licensing content never seen - it opens first and stats the handle, failing closed on unreadable files. ODEK_* env vars with invalid numeric values silently fell back to defaults - each ignored value now warns on stderr with the variable name, and list parsers name the full original value.
1 parent ec6e696 commit e01f95f

55 files changed

Lines changed: 3555 additions & 199 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
// RED-first TOCTOU regression tests for the artifact read path
4+
// (cmd/odek/artifact_read_tool.go + the renderArtifacts inline path in
5+
// subagent_tool.go).
6+
//
7+
// Bug: artifact_read re-opened the registered artifact BY PATH at read time
8+
// with plain os.Stat/os.Open — both follow symlinks — and the ref's recorded
9+
// sha256 was never re-checked. A same-user process (the threat model
10+
// includes approved MCP servers) could swap ~/.odek/artifacts/.../<file> for
11+
// a symlink after collation and artifact_read would stream any readable file
12+
// outside all artifact roots into the parent context, paged.
13+
//
14+
// Each test registers a REAL artifact through the production store path
15+
// (registerTaskArtifacts → artifact.Validate), then tampers with the file
16+
// and asserts the read fails closed instead of returning foreign content.
17+
18+
import (
19+
"fmt"
20+
"os"
21+
"path/filepath"
22+
"strings"
23+
"testing"
24+
25+
"github.com/BackendStack21/odek/internal/artifact"
26+
)
27+
28+
// registerArtifactForTOCTOU writes one artifact file into a dedicated task
29+
// dir and registers it through the production path (registerTaskArtifacts,
30+
// which runs artifact.Validate against that dir as the only root). Returns
31+
// the registered root dir and the on-disk artifact path.
32+
func registerArtifactForTOCTOU(t *testing.T, id, content string) (root, path string) {
33+
t.Helper()
34+
root = filepath.Join(t.TempDir(), "task-root")
35+
if err := os.MkdirAll(root, 0o700); err != nil {
36+
t.Fatal(err)
37+
}
38+
path = filepath.Join(root, id+".md")
39+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
40+
t.Fatal(err)
41+
}
42+
raw := fmt.Sprintf(`{"status":"success","summary":"ok","artifacts":[{"schema":%q,"id":%q,"uri":"file://%s","media_type":"text/markdown","sha256":%q,"size_bytes":%d}]}`,
43+
artifact.SchemaArtifactRef, id, path, expectedSHA(t, content), len(content))
44+
if notes := registerTaskArtifacts(raw, root, 0); len(notes) != 0 {
45+
t.Fatalf("clean registration must not produce notes: %v", notes)
46+
}
47+
// The registration helper silently skips validation failures — make
48+
// sure the entry actually landed before tampering.
49+
if _, ok := lookupSubagentArtifact(id); !ok {
50+
t.Fatal("artifact was not registered (validation silently dropped it)")
51+
}
52+
return root, path
53+
}
54+
55+
func newArtifactReadToolForTOCTOU(t *testing.T) *artifactReadTool {
56+
t.Helper()
57+
tool := &artifactReadTool{}
58+
tool.SetContext(t.Context())
59+
return tool
60+
}
61+
62+
// TestArtifactReadTool_SymlinkSwapOutsideRootsRejected pins the reported
63+
// bug: swapping the artifact file for a symlink pointing OUTSIDE the
64+
// artifact roots after registration must fail the read — never stream the
65+
// symlink target's content.
66+
func TestArtifactReadTool_SymlinkSwapOutsideRootsRejected(t *testing.T) {
67+
resetArtifactRegistryForTest()
68+
root, path := registerArtifactForTOCTOU(t, "report", "# Report\nlegit findings")
69+
70+
// A sibling of the artifact root — outside every registered root.
71+
secretDir := filepath.Join(filepath.Dir(root), "outside")
72+
if err := os.MkdirAll(secretDir, 0o700); err != nil {
73+
t.Fatal(err)
74+
}
75+
secret := filepath.Join(secretDir, "secret.txt")
76+
const secretBody = "TOP SECRET outside-root payload"
77+
if err := os.WriteFile(secret, []byte(secretBody), 0o600); err != nil {
78+
t.Fatal(err)
79+
}
80+
81+
// The swap: same directory entry, now a symlink out of the roots.
82+
if err := os.Remove(path); err != nil {
83+
t.Fatal(err)
84+
}
85+
if err := os.Symlink(secret, path); err != nil {
86+
t.Fatal(err)
87+
}
88+
// Guard the test's own premise: the final component IS a symlink now.
89+
if fi, err := os.Lstat(path); err != nil || fi.Mode()&os.ModeSymlink == 0 {
90+
t.Fatalf("test setup: swap did not produce a symlink (err=%v)", err)
91+
}
92+
93+
got, err := newArtifactReadToolForTOCTOU(t).Call(`{"id":"report"}`)
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
if strings.Contains(got, secretBody) {
98+
t.Errorf("symlinked target content leaked into the parent context:\n%s", got)
99+
}
100+
if !strings.Contains(got, `"error"`) {
101+
t.Errorf("swapped artifact must fail the read with an error, got:\n%s", got)
102+
}
103+
}
104+
105+
// TestArtifactReadTool_ReplacedContentRejected pins the digest half of the
106+
// fix: replacing the file with a same-size regular file (no symlink — so
107+
// only the sha256 re-check can catch it) must fail the read.
108+
func TestArtifactReadTool_ReplacedContentRejected(t *testing.T) {
109+
resetArtifactRegistryForTest()
110+
original := strings.Repeat("A", 64)
111+
_, path := registerArtifactForTOCTOU(t, "blob", original)
112+
113+
// Same length, different bytes: size_bytes still matches, only the
114+
// digest betrays the swap.
115+
swapped := strings.Repeat("B", 64)
116+
if err := os.WriteFile(path, []byte(swapped), 0o600); err != nil {
117+
t.Fatal(err)
118+
}
119+
120+
got, err := newArtifactReadToolForTOCTOU(t).Call(`{"id":"blob"}`)
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
if strings.Contains(got, swapped) {
125+
t.Errorf("replaced content leaked into the parent context:\n%s", got)
126+
}
127+
if !strings.Contains(got, `"error"`) {
128+
t.Errorf("digest mismatch must fail the read with an error, got:\n%s", got)
129+
}
130+
}
131+
132+
// TestArtifactReadTool_UnhashableRefRejected pins fail-closed behavior for
133+
// refs registered without a sha256: with nothing recorded to verify against,
134+
// the read must refuse rather than serve unverified bytes.
135+
func TestArtifactReadTool_UnhashableRefRejected(t *testing.T) {
136+
resetArtifactRegistryForTest()
137+
dir := t.TempDir()
138+
path := filepath.Join(dir, "bare.md")
139+
const body = "no digest recorded for me"
140+
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
141+
t.Fatal(err)
142+
}
143+
size := int64(len(body))
144+
registerSubagentArtifact(artifactEntry{Ref: artifact.Ref{
145+
Schema: artifact.SchemaArtifactRef, ID: "bare", MediaType: "text/markdown",
146+
URI: "file://" + path, SizeBytes: &size,
147+
// SHA256 intentionally absent.
148+
}, Path: path, TaskIdx: 0})
149+
150+
got, err := newArtifactReadToolForTOCTOU(t).Call(`{"id":"bare"}`)
151+
if err != nil {
152+
t.Fatal(err)
153+
}
154+
if strings.Contains(got, body) {
155+
t.Errorf("unverifiable ref must not be served:\n%s", got)
156+
}
157+
if !strings.Contains(got, `"error"`) {
158+
t.Errorf("sha256-less ref must fail closed with an error, got:\n%s", got)
159+
}
160+
}
161+
162+
// TestArtifactReadTool_UnchangedFileStillReads is the positive control: an
163+
// untouched artifact reads exactly as before the hardening.
164+
func TestArtifactReadTool_UnchangedFileStillReads(t *testing.T) {
165+
resetArtifactRegistryForTest()
166+
const body = "# Report\nlegit findings"
167+
_, _ = registerArtifactForTOCTOU(t, "report", body)
168+
169+
got, err := newArtifactReadToolForTOCTOU(t).Call(`{"id":"report"}`)
170+
if err != nil {
171+
t.Fatal(err)
172+
}
173+
if !strings.Contains(got, body) {
174+
t.Errorf("unchanged artifact must still read:\n%s", got)
175+
}
176+
if strings.Contains(got, `"error"`) {
177+
t.Errorf("unchanged artifact must not error:\n%s", got)
178+
}
179+
}

0 commit comments

Comments
 (0)