Files
zero/internal/tools/update_plan.go
75f15da82a Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening (#119)
* Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening

First module of the tools->agent->tui chain that leads to the TUI shell (off main). Scope: internal/tools (+ a 1-line specialist vocabulary update).

3-way merged onto main's specialist drift (RunOptions tool-context fields, Safety.AdvertiseInAuto, optionsAwareTool dispatch, PropertySchema, base structured Result) and grafted the runtime-core additions:

- Shared arg-tolerance layer (argtolerance.go: alias-aware/type-strict arg resolution + slice coercion) adopted across all tools so weak models' non-canonical arg shapes are accepted; bool/int coercion in args.go.

- ask_user tool (interactive clarifying questions) wired into CoreReadOnlyTools; nested PropertySchema (Properties/Required) for its object questions + update_plan.

- Secret scrubbing at the registry boundary (scrubResultSecrets via internal/redaction) applied to every tool-execution path, incl. the optionsAwareTool dispatch; structured Result (Display/Redacted/ChangedFiles).

- mutation_targets (paths a mutating tool will write, for session checkpointing) + hardened grep (symlink confinement), apply_patch, bash, edit/write.

EXCLUDED per the module split: the blueprint task tool (subagents — another dev's area; the merged specialist Task is separate) and the skill tool (ships with the later skills module); plus their subagent-only registry helpers (Without/isolateForChild).

ask_user is now a core tool, so internal/specialist knownToolNames adds "ask_user" to keep the TestKnownToolNamesMatchCoreRegistry cross-package invariant. No new deps; build/vet/-race/windows/full-suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (CodeRabbit): scrub Display, validate apply_patch mutation paths, narrow test skips

- registry.go: scrubResultSecrets now redacts Result.Display.Summary too (not just Output), so a caller preferring Display can't bypass the boundary redaction.

- mutation_targets.go: apply_patch targets now run through validatePatchPaths(applyRoot, ...) before being returned, so a traversal patch (../x) can't yield an out-of-workspace checkpoint target. Regression case added to TestMutationTargetsRejectsEscapingPaths.

- argtolerance_test/write_tools_test: the apply_patch tests no longer Skipf on ANY non-OK result (which masked real diff/apply regressions); they skip ONLY when the git binary is unavailable (gitApplyUnavailable helper checks exec's 'executable file not found') and otherwise Fatalf.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 re-review (CodeRabbit): best-effort multiSelect + Display.Summary scrub coverage

- ask_user: an uncoercible multiSelect no longer fails the whole call — it's a UI hint, so it defaults to false (best-effort, mirroring the lenient options path). Test: TestParseAskUserQuestionsLenientOptions extended.

- registry_test: TestRunWithOptionsScrubsSecretsForAllCallers now also leaks a secret via Result.Display.Summary and asserts it is redacted, locking in the boundary scrub for the structured-result path (secretTool gained a display field).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (@Vasanthdev2004): scrub ALL registry return paths + harden intArg

[blocker] RunWithOptions now scrubs every return path, not just tool-execution: a named return + a single 'defer scrubResultSecrets(result)' covers the unknown-tool, sandbox-deny, sandbox-approval-required, permission-required and permission-denied early returns too. Previously a sandbox denial that echoed a secret-bearing path/arg returned it raw with Redacted=false. Regression: TestRunWithOptionsScrubsSecretsOnDenialPaths (permission-denied reason carrying a token -> scrubbed + Redacted).

[non-blocking] intArg fails closed on NaN/Inf/out-of-range floats (and their string forms) via a floatToInt helper, before any implementation-defined cast. Tests added for NaN/Inf/NaN-string/non-integral.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (CodeRabbit grep TOCTOU + anandh8x dependency blockers)

- grep: close the confinement TOCTOU — confineGrepFile now returns the symlink-RESOLVED path and collectGrepMatches reads THAT (not the raw candidate), so a symlink swapped in between check and read can't escape the workspace.

- ask_user: removed from CoreReadOnlyTools. It needs the agent loop's interactive intercept (OnAskUser), which is in the (unmerged) agent module; without it the tool only returns the non-interactive fallback. The tool still ships (NewAskUserTool); the agent module registers it in core + wires the intercept together. Reverted the specialist knownToolNames 'ask_user' entry + the ask_user-in-core test accordingly.

- update_plan: reverted the plan schema to a flat array (item structure documented in the description). The structured nested-object Items schema depends on the agent's PropertySchema serializer (propertyToRuntimeMap) passing nested Properties/Required through to providers, which is in the agent module; a nested schema here would be silently dropped. PropertySchema.Properties/Required fields are kept (used by ask_user.go's own schema + the later MCP module).

build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Harden #119 from adversarial self-review: sandbox alias-gate, Meta scrub, intArg 2^63

A multi-dimension adversarial review of the tools diff surfaced 3 real issues beyond the reviewer rounds:

- [major/security] write_file/edit_file path aliases file_path/filename/filepath bypassed the sandbox workspace+symlink gate: sandbox.requestPaths only inspected path/cwd/file/dir, so an aliased target skipped enforcement (same class as audit H4). Aligned requestPaths with the tools' path-alias lists (internal/sandbox/risk.go). The tools' own resolveWorkspaceTargetPath still confined, but the sandbox LAYER/audit/symlink-check was silently skipped. Regression: TestRegistrySandboxGatesPathAliasKeys.

- [minor] scrubResultSecrets skipped Result.Meta, which is forwarded to the transcript and carries model-controlled strings (glob pattern, bash cwd). Now scrubs Meta values too. Covered in TestRunWithOptionsScrubsSecretsForAllCallers.

- [minor] floatToInt used f > float64(MaxInt); that constant rounds up to 2^63 so exactly 2^63 slipped to an out-of-range cast. Changed to >=. Test added for 2^63 float+string.

build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 17:13:27 +05:30

148 lines
4.4 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"sync"
)
type PlanItem struct {
ID string
Content string
Status string
Notes string
}
type updatePlanTool struct {
baseTool
// mu guards currentPlan: Run() writes it on the agent goroutine while
// CurrentPlan()/ClearPlan() are called from the TUI goroutine (e.g. /plan).
mu sync.Mutex
currentPlan []PlanItem
}
func NewUpdatePlanTool() *updatePlanTool {
return &updatePlanTool{
baseTool: baseTool{
name: "update_plan",
description: "Create or update the in-memory plan for the current task. " +
"Pass the full ordered list of steps each call; it replaces the previous plan. " +
"Each item needs a `content` string; `status` defaults to \"pending\" and `id` is " +
"auto-numbered, so you only need to supply `content` (and `status` as the task progresses).",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
// The item structure (content/status/id) is described textually
// above. A structured nested-object Items schema is deferred until
// the agent's PropertySchema serializer (propertyToRuntimeMap)
// passes nested Properties/Required through to providers; until then
// a nested schema here would be silently dropped, so keep it flat.
"plan": {
Type: "array",
Description: "Ordered list of plan items, replacing any previous plan. Each item is an object with a required `content` string, an optional `status` (pending|in_progress|completed|failed, defaults to pending), optional `notes`, and an optional `id` (auto-numbered from position when omitted).",
},
},
Required: []string{"plan"},
AdditionalProperties: false,
},
safety: readOnlySafety("Updates in-memory planning state only."),
},
}
}
func (tool *updatePlanTool) Run(_ context.Context, args map[string]any) Result {
plan, err := parsePlanItems(args["plan"])
if err != nil {
return errorResult("Error: Invalid arguments for update_plan: " + err.Error())
}
tool.mu.Lock()
tool.currentPlan = plan
tool.mu.Unlock()
return okResult(formatPlan(plan))
}
func (tool *updatePlanTool) CurrentPlan() []PlanItem {
tool.mu.Lock()
defer tool.mu.Unlock()
return append([]PlanItem{}, tool.currentPlan...)
}
func (tool *updatePlanTool) ClearPlan() {
tool.mu.Lock()
tool.currentPlan = nil
tool.mu.Unlock()
}
func parsePlanItems(value any) ([]PlanItem, error) {
items, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("plan must be an array")
}
plan := make([]PlanItem, 0, len(items))
for index, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("plan item %d must be an object", index+1)
}
content, err := stringArg(object, "content", "", true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
// id is optional: weaker models can't reliably mint stable ids, and the
// plan is displayed by 1-based position anyway. Auto-number when omitted.
id, err := stringArgWithEmpty(object, "id", fmt.Sprintf("%d", index+1), false, true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
if id == "" {
id = fmt.Sprintf("%d", index+1)
}
// status is optional and defaults to pending.
status, err := stringArgWithEmpty(object, "status", "pending", false, true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
if status == "" {
status = "pending"
}
if !isPlanStatus(status) {
return nil, fmt.Errorf("plan item %d status must be pending, in_progress, completed, or failed", index+1)
}
notes, err := stringArgWithEmpty(object, "notes", "", false, true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
plan = append(plan, PlanItem{
ID: id,
Content: content,
Status: status,
Notes: notes,
})
}
return plan, nil
}
func isPlanStatus(status string) bool {
return status == "pending" || status == "in_progress" || status == "completed" || status == "failed"
}
func formatPlan(plan []PlanItem) string {
if len(plan) == 0 {
return "Plan is currently empty."
}
lines := make([]string, 0, len(plan))
for index, item := range plan {
line := fmt.Sprintf("%d. [%s] %s", index+1, item.Status, item.Content)
if item.Notes != "" {
line += "\n Notes: " + item.Notes
}
lines = append(lines, line)
}
return "Current Plan:\n" + strings.Join(lines, "\n")
}