Files
Vasanth T 4ca4fa75d3 fix(agent): end the additional_permissions retry loop (#864)
* fix(agent): end the additional_permissions retry loop

Asking the agent to run an ordinary read-only command on Windows could leave it
oscillating between two rejections instead of executing:

    additional_permissions requires sandbox_permissions set to "with_additional_permissions"
    additional_permissions must include at least one of network or file_system, ...

Both come from inlineAdditionalPermissionsProfile, and each names only the fix
that ADDS something. The first pushes the model toward setting the flag; the
second pushes it back toward requesting a permission the command does not need.
Neither ever says to omit the field, which is the correct action for a read-only
command, so the two messages form a two-state oscillator. Every rejection now
carries that remedy.

The schema half is why the object is attached at all. Neither field description
said to omit them for ordinary commands, so a model that legitimately used
with_additional_permissions once in a session had nothing telling it to stop
attaching a permission object to the next command. Both descriptions now lead
with "omit", and they are shared constants rather than duplicated verbatim in
bash.go and exec_command.go, where they were free to drift.

Rejection is still rejection. Nothing that was refused is now accepted on the
strength of an empty or mismatched payload, and sandbox_permissions is never
upgraded on the model's behalf; only the guidance changed.

The one behavioural change is that an explicit additional_permissions: null is
now treated as the omitted field, since a null grants nothing and carries no
flag. That flips three shapes from reject to execute: null with use_default,
with require_escalated, and with no mode at all. The escalated one still routes
through the normal escalation prompt and is not a new capability — the identical
request without the null key already behaved that way. A FLAGGED null still
errors, so with_additional_permissions plus null is not silently downgraded.

Fixes #845. Fixes #847.

* fix(tools): condition the omit guidance on needed access, not read-only

The description said to omit sandbox_permissions "for ordinary commands,
including every read-only one". That overstates it: curl and gh are read-only
and still need sandboxed network, so a model following it literally would omit
the override on a command that genuinely needs one and hit the opposite failure.

It now says to omit unless the command needs access the sandbox does not already
give it. That is the claim that is actually true, and it is one a model can act
on without first deciding what counts as read-only.

The schema test asserted the wrong half too — a bare substring check for
"read-only" pinned the overstatement rather than the contract. It now pins the
conditional, with the reasoning recorded so the weaker assertion does not come
back.

Reported by CodeRabbit on #864.
2026-08-06 21:34:54 +05:30

183 lines
6.0 KiB
Go

package tools
import (
"fmt"
"math"
"strconv"
"strings"
)
func stringArg(args map[string]any, key string, fallback string, required bool) (string, error) {
return stringArgWithEmpty(args, key, fallback, required, false)
}
func stringArgWithEmpty(args map[string]any, key string, fallback string, required bool, allowEmpty bool) (string, error) {
value, ok := args[key]
if !ok || value == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return fallback, nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if !allowEmpty && text == "" {
return "", fmt.Errorf("%s must be a non-empty string", key)
}
return text, nil
}
// boolArg reads a boolean argument, tolerating the string/number forms models
// commonly emit ("true"/"false", "yes"/"no", "on"/"off", 1/0) since not every
// model sends a JSON boolean.
func boolArg(args map[string]any, key string, fallback bool) (bool, error) {
value, ok := args[key]
if !ok || value == nil {
return fallback, nil
}
switch typed := value.(type) {
case bool:
return typed, nil
case string:
switch strings.ToLower(strings.TrimSpace(typed)) {
case "true", "yes", "on", "1":
return true, nil
case "false", "no", "off", "0":
return false, nil
}
case float64:
if typed == 1 {
return true, nil
}
if typed == 0 {
return false, nil
}
case int:
if typed == 1 {
return true, nil
}
if typed == 0 {
return false, nil
}
}
return false, fmt.Errorf("%s must be a boolean", key)
}
// floatToInt converts a float to int only when it is finite, integral, and in
// range; NaN/Inf/non-integer/out-of-range return ok=false so callers fail closed
// before an implementation-defined cast.
func floatToInt(f float64) (int, bool) {
// Use >= against float64(math.MaxInt): that constant rounds UP to 2^63, so a
// strict > would let exactly 2^63 through to an out-of-range int(f) cast. The
// largest representable in-range float (2^63-1024) is still < it, so nothing
// valid is excluded. MinInt (-2^63) is exactly representable, so > is correct there.
if math.IsNaN(f) || math.IsInf(f, 0) || math.Trunc(f) != f || f >= float64(math.MaxInt) || f < float64(math.MinInt) {
return 0, false
}
return int(f), true
}
func intArg(args map[string]any, key string, fallback int, min int, max int) (int, error) {
value, ok := args[key]
if !ok || value == nil {
return fallback, nil
}
var number int
switch typed := value.(type) {
case int:
number = typed
case int32:
number = int(typed)
case int64:
number = int(typed)
case float64:
n, ok := floatToInt(typed)
if !ok {
return 0, fmt.Errorf("%s must be an integer", key)
}
number = n
case string:
// Some models send numbers as strings ("5"); accept whole numbers.
trimmed := strings.TrimSpace(typed)
if parsed, perr := strconv.Atoi(trimmed); perr == nil {
number = parsed
} else if f, ferr := strconv.ParseFloat(trimmed, 64); ferr == nil {
n, ok := floatToInt(f)
if !ok {
return 0, fmt.Errorf("%s must be an integer", key)
}
number = n
} else {
return 0, fmt.Errorf("%s must be an integer", key)
}
default:
return 0, fmt.Errorf("%s must be an integer", key)
}
if number < min {
return 0, fmt.Errorf("%s must be at least %d", key, min)
}
if max > 0 && number > max {
return 0, fmt.Errorf("%s must be at most %d", key, max)
}
return number, nil
}
func intPtr(value int) *int {
return &value
}
// sandboxPermissionsDescription and additionalPermissionsDescription are shared
// by every shell tool (bash, exec_command) so the two fields cannot drift apart.
//
// Both lead with "omit" on purpose. The pair used to be described only in terms
// of what each value does, which left a model that had used
// with_additional_permissions once (say, for `gh issue create`) no instruction
// to stop attaching a permission object to the next, ordinary command. That
// attachment is then rejected — correctly, but the rejection alone does not tell
// the model that dropping the field is the fix, so it flips between an
// unflagged permission object and a flagged empty one and never converges
// (#845, #847). Making "omit it" the first sentence of both descriptions stops
// the invalid shape from being emitted in the first place; the validator errors
// are the backstop, not the teacher.
//
// These ride on every turn's exec_command schema, so they are kept close to the
// length of the text they replaced; TestEagerToolSchemaTokenBudget is the ratchet.
const (
sandboxPermissionsDescription = "Per-command sandbox override. Omit it unless the command needs access the " +
"sandbox does not already give it; use `with_additional_permissions` with a non-empty " +
"`additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command " +
"must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by " +
"sandbox namespaces."
additionalPermissionsDescription = "Extra sandbox access. Omit this field entirely unless `sandbox_permissions` " +
"is `with_additional_permissions`; sending it with any other mode is rejected, as is an empty or all-false " +
"object. Example: {\"network\": {\"enabled\": true}}."
)
func additionalPermissionsProperties() map[string]PropertySchema {
return requestPermissionsProfileSchema().Properties
}
func sandboxPermissionsArg(args map[string]any) (SandboxPermissionOverride, error) {
raw, err := stringArgWithEmpty(args, "sandbox_permissions", string(SandboxPermissionsUseDefault), false, false)
if err != nil {
return "", err
}
switch SandboxPermissionOverride(strings.TrimSpace(raw)) {
case "", SandboxPermissionsUseDefault:
return SandboxPermissionsUseDefault, nil
case SandboxPermissionsWithAdditionalPermissions:
return SandboxPermissionsWithAdditionalPermissions, nil
case SandboxPermissionsRequireEscalated:
return SandboxPermissionsRequireEscalated, nil
default:
return "", fmt.Errorf("sandbox_permissions must be %q, %q, or %q", SandboxPermissionsUseDefault, SandboxPermissionsWithAdditionalPermissions, SandboxPermissionsRequireEscalated)
}
}