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) } }