Skip to content

Commit 93617e1

Browse files
authored
Merge pull request #2480 from dgageot/board/code-review-find-bugs-and-security-issue-d9ce6d2b
fix: harden artifact store, skills loader, hooks, shell and agent warnings
2 parents 8c600d3 + 13579aa commit 93617e1

10 files changed

Lines changed: 408 additions & 31 deletions

File tree

pkg/agent/agent.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"math/rand"
8+
"sync"
89
"sync/atomic"
910
"time"
1011

@@ -39,8 +40,13 @@ type Agent struct {
3940
addPromptFiles []string
4041
tools []tools.Tool
4142
commands types.Commands
42-
pendingWarnings []string
4343
hooks *latest.HooksConfig
44+
45+
// warningsMu guards pendingWarnings. addToolWarning and DrainWarnings
46+
// may be called concurrently from the runtime loop, the MCP server,
47+
// the TUI and session manager.
48+
warningsMu sync.Mutex
49+
pendingWarnings []string
4450
}
4551

4652
// New creates a new agent
@@ -286,14 +292,15 @@ func (a *Agent) addToolWarning(msg string) {
286292
if msg == "" {
287293
return
288294
}
295+
a.warningsMu.Lock()
289296
a.pendingWarnings = append(a.pendingWarnings, msg)
297+
a.warningsMu.Unlock()
290298
}
291299

292300
// DrainWarnings returns pending warnings and clears them.
293301
func (a *Agent) DrainWarnings() []string {
294-
if len(a.pendingWarnings) == 0 {
295-
return nil
296-
}
302+
a.warningsMu.Lock()
303+
defer a.warningsMu.Unlock()
297304
warnings := a.pendingWarnings
298305
a.pendingWarnings = nil
299306
return warnings

pkg/agent/agent_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"errors"
66
"log/slog"
7+
"sync"
78
"testing"
9+
"time"
810

911
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
@@ -296,3 +298,54 @@ func TestAgentNoDuplicateStartWarnings(t *testing.T) {
296298
require.NoError(t, err)
297299
assert.Empty(t, a.DrainWarnings(), "turn 3: no duplicate warning on repeated failure")
298300
}
301+
302+
// TestAgentWarningsConcurrentAccess exercises the warnings queue from
303+
// multiple goroutines to catch regressions in locking. Run with -race to
304+
// actually detect a regression.
305+
func TestAgentWarningsConcurrentAccess(t *testing.T) {
306+
t.Parallel()
307+
308+
a := New("root", "test")
309+
310+
const writers = 8
311+
const drainers = 4
312+
const perWriter = 200
313+
314+
var wg sync.WaitGroup
315+
wg.Add(writers + drainers)
316+
317+
for range writers {
318+
go func() {
319+
defer wg.Done()
320+
for range perWriter {
321+
a.addToolWarning("boom")
322+
}
323+
}()
324+
}
325+
326+
stop := make(chan struct{})
327+
for range drainers {
328+
go func() {
329+
defer wg.Done()
330+
for {
331+
select {
332+
case <-stop:
333+
// One final drain so we can assert a total count.
334+
_ = a.DrainWarnings()
335+
return
336+
default:
337+
_ = a.DrainWarnings()
338+
}
339+
}
340+
}()
341+
}
342+
343+
// Give writers a little time to finish, then signal drainers to stop.
344+
time.Sleep(20 * time.Millisecond)
345+
close(stop)
346+
wg.Wait()
347+
348+
// A successful run means no data race and no panic; we don't assert a
349+
// specific number of warnings drained because drainers run concurrently
350+
// with writers.
351+
}

pkg/content/store.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"io"
1111
"os"
1212
"path/filepath"
13+
"regexp"
1314
"strings"
1415
"time"
1516

@@ -18,6 +19,23 @@ import (
1819
"github.com/google/go-containerregistry/pkg/v1/tarball"
1920
)
2021

22+
// ErrInvalidDigest indicates that an identifier shaped like a digest
23+
// (e.g. "sha256:...") does not match the expected format. Rejecting these
24+
// prevents path-traversal when the digest is used as a filename component.
25+
var ErrInvalidDigest = errors.New("invalid artifact digest")
26+
27+
// sha256DigestRe matches a well-formed sha256 digest: 64 lowercase hex chars.
28+
var sha256DigestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
29+
30+
// validateDigest returns nil if digest is a well-formed sha256 digest,
31+
// or a wrapped ErrInvalidDigest otherwise.
32+
func validateDigest(digest string) error {
33+
if !sha256DigestRe.MatchString(digest) {
34+
return fmt.Errorf("%w: %q", ErrInvalidDigest, digest)
35+
}
36+
return nil
37+
}
38+
2139
// ErrStoreCorrupted indicates that the local artifact store is in an
2240
// inconsistent or partially missing state (e.g. missing tar, refs or metadata).
2341
// Callers may safely attempt to re-fetch the artifact from the remote source.
@@ -79,6 +97,11 @@ func (s *Store) StoreArtifact(img v1.Image, reference string) (string, error) {
7997

8098
digestStr := digest.String()
8199

100+
// Validate the digest before using it in filesystem paths (defense-in-depth).
101+
if err := validateDigest(digestStr); err != nil {
102+
return "", err
103+
}
104+
82105
tarPath := filepath.Join(s.baseDir, digestStr+".tar")
83106

84107
if err := crane.Save(img, reference, tarPath); err != nil {
@@ -266,27 +289,32 @@ func (s *Store) DeleteArtifact(identifier string) error {
266289

267290
// resolveIdentifier resolves a user-provided identifier (digest or reference)
268291
// into a concrete content digest stored in the local artifact store.
292+
//
293+
// The returned digest is always strictly validated ("sha256:" + 64 hex chars)
294+
// so it can be safely used as a filename component without enabling path
295+
// traversal.
269296
func (s *Store) resolveIdentifier(identifier string) (string, error) {
270-
// If the identifier is already a bare digest, return it directly.
297+
// Bare digest, e.g. "sha256:abc123...".
271298
if strings.HasPrefix(identifier, "sha256:") {
299+
if err := validateDigest(identifier); err != nil {
300+
return "", err
301+
}
272302
return identifier, nil
273303
}
274304

275-
// If the identifier is a digest reference (e.g. "repo@sha256:abc..."),
276-
// extract and return the digest portion directly. Digest references
277-
// are content-addressable, so the digest alone identifies the artifact.
305+
// Digest reference, e.g. "repo@sha256:abc123...".
278306
if i := strings.LastIndex(identifier, "@sha256:"); i >= 0 {
279-
return identifier[i+1:], nil
307+
digest := identifier[i+1:]
308+
if err := validateDigest(digest); err != nil {
309+
return "", err
310+
}
311+
return digest, nil
280312
}
281313

282-
// If no tag is provided, default to ":latest".
283-
// This mirrors standard OCI reference semantics.
314+
// Tagged or tag-less reference. Default to ":latest" per OCI semantics.
284315
if !strings.Contains(identifier, ":") {
285316
identifier += ":latest"
286317
}
287-
288-
// Resolve the reference to a digest via the refs store.
289-
// Any failure here indicates the local store is missing or inconsistent.
290318
return s.resolveReference(identifier)
291319
}
292320

@@ -312,8 +340,14 @@ func (s *Store) resolveReference(reference string) (string, error) {
312340
return "", fmt.Errorf("reading reference file: %w", err)
313341
}
314342

315-
// The file content is expected to be the digest string.
316-
return strings.TrimSpace(string(data)), nil
343+
// The file content is expected to be the digest string. Refs files are
344+
// generated by us, but we validate defense-in-depth in case the refs
345+
// directory is ever tampered with.
346+
digest := strings.TrimSpace(string(data))
347+
if err := validateDigest(digest); err != nil {
348+
return "", fmt.Errorf("ref %q: %w", reference, err)
349+
}
350+
return digest, nil
317351
}
318352

319353
// createReferenceLink creates a link from reference to digest

pkg/content/store_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package content
22

33
import (
44
"fmt"
5+
"os"
6+
"path/filepath"
57
"testing"
68

79
"github.com/google/go-containerregistry/pkg/v1/empty"
@@ -140,3 +142,42 @@ func TestStoreResolution_DigestReference(t *testing.T) {
140142
require.NoError(t, err)
141143
assert.Equal(t, digest, meta.Digest)
142144
}
145+
146+
// TestStoreResolution_RejectsMalformedDigest ensures that identifiers
147+
// shaped like a digest but carrying path-traversal sequences are rejected
148+
// before they can be joined into a filesystem path.
149+
func TestStoreResolution_RejectsMalformedDigest(t *testing.T) {
150+
baseDir := t.TempDir()
151+
store, err := NewStore(WithBaseDir(baseDir))
152+
require.NoError(t, err)
153+
154+
// Create a sentinel file outside baseDir to confirm we don't touch it.
155+
sentinelDir := t.TempDir()
156+
sentinel := filepath.Join(sentinelDir, "sentinel.tar")
157+
require.NoError(t, os.WriteFile(sentinel, []byte("keep me"), 0o600))
158+
159+
malformed := []string{
160+
"sha256:../../etc/passwd",
161+
"sha256:../" + filepath.Base(sentinelDir) + "/sentinel",
162+
"sha256:",
163+
"sha256:deadbeef", // too short
164+
// non-hex char in an otherwise 64-char body
165+
"sha256:z0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde",
166+
"repo@sha256:../../oops",
167+
}
168+
169+
for _, id := range malformed {
170+
_, err := store.GetArtifactImage(id)
171+
require.ErrorIsf(t, err, ErrInvalidDigest, "id=%q", id)
172+
173+
_, err = store.GetArtifactPath(id)
174+
require.ErrorIsf(t, err, ErrInvalidDigest, "id=%q", id)
175+
176+
err = store.DeleteArtifact(id)
177+
require.ErrorIsf(t, err, ErrInvalidDigest, "id=%q", id)
178+
}
179+
180+
// Sentinel must be untouched.
181+
_, err = os.Stat(sentinel)
182+
require.NoError(t, err, "sentinel file should not have been affected")
183+
}

pkg/hooks/executor.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,19 @@ func (e *Executor) executeHook(ctx context.Context, hook Hook, inputJSON []byte)
281281
// Run command
282282
err := cmd.Run()
283283

284+
// A fired timeout or parent-context cancellation surfaces as a non-nil
285+
// error whose Go type varies (often *exec.ExitError with ExitCode()==-1).
286+
// Normalize to a plain execution error so PreToolUse gates can fail
287+
// closed rather than look at a meaningless exit code.
288+
if ctxErr := timeoutCtx.Err(); ctxErr != nil {
289+
reason := "cancelled"
290+
if errors.Is(ctxErr, context.DeadlineExceeded) {
291+
reason = fmt.Sprintf("timed out after %s", hook.GetTimeout())
292+
}
293+
return nil, stdout.String(), stderr.String(), -1,
294+
fmt.Errorf("hook %q %s: %w", hook.Command, reason, ctxErr)
295+
}
296+
284297
exitCode := 0
285298
if err != nil {
286299
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
@@ -318,7 +331,18 @@ func (e *Executor) aggregateResults(results []hookResult, eventType EventType) (
318331

319332
for _, r := range results {
320333
if r.err != nil {
321-
slog.Warn("Hook execution error", "error", r.err)
334+
// PreToolUse is a security boundary: if a hook fails to
335+
// produce a verdict (timeout, spawn failure, missing binary),
336+
// deny the tool call rather than silently letting it through.
337+
if eventType == EventPreToolUse {
338+
slog.Warn("PreToolUse hook failed to execute; denying tool call", "error", r.err)
339+
finalResult.Allowed = false
340+
finalResult.ExitCode = -1
341+
finalResult.Stderr = r.stderr
342+
messages = append(messages, fmt.Sprintf("PreToolUse hook failed to execute: %v", r.err))
343+
} else {
344+
slog.Warn("Hook execution error", "error", r.err)
345+
}
322346
continue
323347
}
324348

pkg/hooks/hooks_test.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,72 @@ func TestExecuteHooksWithContextCancellation(t *testing.T) {
632632

633633
result, err := exec.ExecutePreToolUse(ctx, input)
634634
require.NoError(t, err)
635-
// Should be allowed because the hook timed out (non-blocking error)
635+
// PreToolUse is a security boundary: when the hook fails to run to
636+
// completion (here, the parent context was cancelled before the hook
637+
// could report a verdict), the tool call must be denied rather than
638+
// silently allowed.
639+
assert.False(t, result.Allowed)
640+
assert.Equal(t, -1, result.ExitCode)
641+
assert.Contains(t, result.Message, "PreToolUse hook failed to execute")
642+
}
643+
644+
// A hook that exits with a non-zero, non-2 code is a non-blocking error:
645+
// it is reported as such in the result but does not deny the tool call.
646+
// Pair this with TestExecuteHooksWithContextCancellation, which asserts the
647+
// opposite for execution failures (timeout, spawn error).
648+
func TestExecutePreToolUseAllowsNonBlockingExitCode(t *testing.T) {
649+
t.Parallel()
650+
651+
config := &Config{
652+
PreToolUse: []MatcherConfig{
653+
{
654+
Matcher: "*",
655+
Hooks: []Hook{
656+
{Type: HookTypeCommand, Command: "exit 1", Timeout: 5},
657+
},
658+
},
659+
},
660+
}
661+
662+
exec := NewExecutor(config, t.TempDir(), nil)
663+
input := &Input{
664+
SessionID: "test-session",
665+
ToolName: "shell",
666+
ToolUseID: "test-id",
667+
}
668+
669+
result, err := exec.ExecutePreToolUse(t.Context(), input)
670+
require.NoError(t, err)
671+
assert.True(t, result.Allowed)
672+
}
673+
674+
func TestExecutePostToolUseDoesNotFailClosedOnError(t *testing.T) {
675+
t.Parallel()
676+
677+
config := &Config{
678+
PostToolUse: []MatcherConfig{
679+
{
680+
Matcher: "*",
681+
Hooks: []Hook{
682+
{Type: HookTypeCommand, Command: "sleep 10", Timeout: 30},
683+
},
684+
},
685+
},
686+
}
687+
688+
exec := NewExecutor(config, t.TempDir(), nil)
689+
input := &Input{
690+
SessionID: "test-session",
691+
ToolName: "shell",
692+
ToolUseID: "test-id",
693+
}
694+
695+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
696+
defer cancel()
697+
698+
result, err := exec.ExecutePostToolUse(ctx, input)
699+
require.NoError(t, err)
700+
// Post-tool-use is observational only: a failed hook must not block
701+
// the already-completed tool call.
636702
assert.True(t, result.Allowed)
637703
}

0 commit comments

Comments
 (0)