Skip to content

Commit 79b65cf

Browse files
committed
build: review pass 5 — close MCP guard bypass + add ctx cancel
HIGH: MCP build_prune footgun guard was bypassed by older_than= "0s" / "0h" / "-1h". The string-based check (args.OlderThan == "") treated those values as "filter set"; ParseDuration accepted them; selectForPrune then silently ignored the zero/negative result (gates on OlderThan > 0). Net: keep_last=N + confirm=true + older_than="0s" passed both guards and reached the near-wipe path the guard exists to prevent. Fix: parse older_than FIRST in buildPruneTool, derive a hasOlderThan boolean from the parsed value (> 0), then run all guards against that boolean. Wording also clarified: "older_than (>0)" in the suggestion messages so an LLM doesn't try "0s" thinking it's valid. MED: Prune's per-entry loop didn't honor ctx cancellation between iterations. Each iteration may run a `docker image rm` (image artifacts) that can take seconds; without an explicit ctx.Err() check, Ctrl+C only takes effect at the next docker round-trip, making the CLI feel unresponsive on long prunes. Added a single ctx.Err() check at the top of each iteration; on cancel, returns (result, context.Canceled) with Plan still populated so the caller sees what would have been done. Tests added: - TestBuildPrune_ZeroDurationDoesNotBypassFootgun (3 sub-cases: "0s", "0h", "-1h" all correctly rejected with the footgun message — the exact LLM mistake the guard exists to prevent) - TestPrune_HonorsContextCancellation (cancel before iteration one; assert context.Canceled propagates and Removed is empty)
1 parent cd13e0c commit 79b65cf

4 files changed

Lines changed: 116 additions & 15 deletions

File tree

internal/build/cache_ops.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,15 @@ func Prune(ctx context.Context, opts PruneOptions) (*PruneResult, error) {
296296
}
297297

298298
for _, e := range plan {
299+
// Honor cancellation between entries — each iteration may
300+
// include a `docker image rm` (image artifacts) that can
301+
// run for tens of seconds; without this check, Ctrl+C only
302+
// takes effect when the next docker call returns. Plan
303+
// remains populated so the caller sees what WOULD have
304+
// been removed at the cancellation point.
305+
if err := ctx.Err(); err != nil {
306+
return result, err
307+
}
299308
// FR-015 lock — same key the builder grabs in Run(). We use
300309
// the non-blocking try-variant: if a build holds this key
301310
// right now, prune skips the entry rather than waiting (a

internal/build/cache_ops_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package build
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"testing"
@@ -318,6 +319,39 @@ func TestPrune_OrphanCleansManifestEvenWithoutArtifact(t *testing.T) {
318319
}
319320
}
320321

322+
// TestPrune_HonorsContextCancellation is the review-pass-5 guard:
323+
// a cancelled ctx must stop the per-entry loop promptly, even mid-
324+
// plan. Without the check, Ctrl+C during a long prune (many image
325+
// entries with slow `docker image rm` calls) only takes effect at
326+
// the next docker round-trip. Plan stays populated so the caller
327+
// can still see what WOULD have been done.
328+
func TestPrune_HonorsContextCancellation(t *testing.T) {
329+
withTempBaseDir(t)
330+
for i := range 5 {
331+
seedJARManifest(t, fmt.Sprintf("entry-%d", i), time.Now(), 100)
332+
}
333+
334+
ctx, cancel := context.WithCancel(context.Background())
335+
cancel() // cancel BEFORE Prune to guarantee the first iteration trips the check
336+
337+
res, err := Prune(ctx, PruneOptions{All: true})
338+
if err == nil {
339+
t.Fatal("expected ctx.Err() to surface from Prune; got nil")
340+
}
341+
if !errors.Is(err, context.Canceled) {
342+
t.Errorf("expected context.Canceled; got %v", err)
343+
}
344+
// Plan should still be populated (we want the caller to know
345+
// what would have been removed at cancellation time).
346+
if len(res.Plan) != 5 {
347+
t.Errorf("Plan should still have 5 entries on cancel; got %d", len(res.Plan))
348+
}
349+
// Removed should be empty — we cancelled before any deletion.
350+
if len(res.Removed) != 0 {
351+
t.Errorf("Removed should be empty when cancelled before first iteration; got %d", len(res.Removed))
352+
}
353+
}
354+
321355
// TestPrune_AcquiresCacheLock pins the review-pass-4 race fix: Prune
322356
// MUST hold the same per-key flock that builder.Run() acquires, so a
323357
// concurrent build of the entry being pruned cannot interleave with

internal/mcp/tools_build.go

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,35 @@ func buildInspectTool(ctx context.Context, _ *mcp.CallToolRequest, args buildIns
114114
}
115115

116116
func buildPruneTool(ctx context.Context, _ *mcp.CallToolRequest, args buildPruneArgs) (*mcp.CallToolResult, any, error) {
117+
// Parse older_than FIRST so subsequent guards see the actual
118+
// effective duration, not the raw string. An LLM passing
119+
// older_than="0s" / "0h" / "-1h" otherwise sneaks past the
120+
// string-based "is this filter set" check while the parsed
121+
// value ends up being silently ignored by selectForPrune
122+
// (which gates on `OlderThan > 0`). Net: the footgun guard
123+
// below would not fire and we'd wipe everything-but-keep_last
124+
// — the exact LLM mistake the guard exists to prevent.
125+
var olderThan time.Duration
126+
if args.OlderThan != "" {
127+
d, err := time.ParseDuration(args.OlderThan)
128+
if err != nil {
129+
return errResult(output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
130+
"older_than %q is not a valid Go duration: %s", args.OlderThan, err.Error()).
131+
WithSuggestions("Use Go duration syntax: '24h', '168h' (= 7 days), '720h' (= 30 days)"))
132+
}
133+
olderThan = d
134+
}
135+
// Effective scoping flag: true only when older_than actually
136+
// narrows the candidate set. Zero or negative durations are
137+
// treated as "not set" so the guards below catch the bypass
138+
// the previous review identified.
139+
hasOlderThan := olderThan > 0
140+
117141
// Same friendly guard as the CLI: empty policy is almost always
118142
// an LLM mistake, not an intent to no-op.
119-
if !args.All && !args.OrphanOnly && args.OlderThan == "" && args.KeepLast == 0 {
143+
if !args.All && !args.OrphanOnly && !hasOlderThan && args.KeepLast == 0 {
120144
return errResult(output.NewError("VALIDATION_ERROR", output.ExitValidationError,
121-
"prune needs at least one of: all, orphan_only, older_than, keep_last").
145+
"prune needs at least one of: all, orphan_only, older_than (>0), keep_last").
122146
WithSuggestions(
123147
"To wipe everything: build_prune with all=true confirm=true",
124148
"To remove orphans only: build_prune with orphan_only=true confirm=true",
@@ -131,28 +155,17 @@ func buildPruneTool(ctx context.Context, _ *mcp.CallToolRequest, args buildPrune
131155
// down to recent". Require either all=true (explicit acknowledge)
132156
// or a scoping filter (orphan_only / older_than).
133157
if args.Confirm && args.KeepLast > 0 &&
134-
!args.All && !args.OrphanOnly && args.OlderThan == "" {
158+
!args.All && !args.OrphanOnly && !hasOlderThan {
135159
return errResult(output.NewError("VALIDATION_ERROR", output.ExitValidationError,
136160
"keep_last alone with confirm=true would wipe everything except "+
137161
"the N newest entries; set all=true to acknowledge, OR "+
138-
"narrow with orphan_only / older_than").
162+
"narrow with orphan_only / older_than (>0)").
139163
WithSuggestions(
140164
"Preview first: omit confirm=true (the plan shows exactly what would be removed)",
141165
"To genuinely wipe-all-but-N: all=true keep_last=N confirm=true",
142166
))
143167
}
144168

145-
var olderThan time.Duration
146-
if args.OlderThan != "" {
147-
d, err := time.ParseDuration(args.OlderThan)
148-
if err != nil {
149-
return errResult(output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
150-
"older_than %q is not a valid Go duration: %s", args.OlderThan, err.Error()).
151-
WithSuggestions("Use Go duration syntax: '24h', '168h' (= 7 days), '720h' (= 30 days)"))
152-
}
153-
olderThan = d
154-
}
155-
156169
res, err := build.Prune(ctx, build.PruneOptions{
157170
All: args.All,
158171
OlderThan: olderThan,

internal/mcp/tools_build_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,51 @@ func TestBuildPrune_KeepLastAloneConfirmRejected(t *testing.T) {
241241
})
242242
}
243243

244+
// TestBuildPrune_ZeroDurationDoesNotBypassFootgun is the review-
245+
// pass-5 hardening for the MCP footgun guard. The previous check
246+
// used `args.OlderThan == ""` to detect "no scoping filter", but
247+
// `older_than: "0s"` (or "0h", or "-1h") parses fine, ends up as
248+
// duration zero / negative, then `selectForPrune`'s `> 0` gate
249+
// silently ignores it — bypassing the guard the LLM cannot have
250+
// realized existed. Fix parses older_than FIRST and treats only
251+
// positive durations as a real scoping filter.
252+
func TestBuildPrune_ZeroDurationDoesNotBypassFootgun(t *testing.T) {
253+
session, cleanup := newConnectedPair(t)
254+
defer cleanup()
255+
256+
cases := []struct {
257+
name string
258+
dur string
259+
}{
260+
{"zero seconds", "0s"},
261+
{"zero hours", "0h"},
262+
{"negative", "-1h"},
263+
}
264+
for _, c := range cases {
265+
t.Run(c.name, func(t *testing.T) {
266+
res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
267+
Name: "build_prune",
268+
Arguments: map[string]any{
269+
"keep_last": 1,
270+
"confirm": true,
271+
"older_than": c.dur,
272+
},
273+
})
274+
if err != nil {
275+
t.Fatalf("CallTool: %v", err)
276+
}
277+
if !res.IsError {
278+
t.Errorf("older_than=%q must NOT bypass the footgun guard; "+
279+
"got IsError=false (would have wiped everything-but-newest)", c.dur)
280+
}
281+
body := extractText(t, res)
282+
if !contains(body, "would wipe everything except") {
283+
t.Errorf("error should mention the footgun; got %q", body)
284+
}
285+
})
286+
}
287+
}
288+
244289
// TestBuildPrune_BadDuration: invalid older_than surfaces a clear
245290
// validation error instead of silently being ignored.
246291
func TestBuildPrune_BadDuration(t *testing.T) {

0 commit comments

Comments
 (0)