// file_delete removes a saved file by its file_id. Decrements the // underlying blob's refcount in storage; the blob row is removed when // refcount hits zero. // // Why scope is checked POST-fetch (mirrors file_get): file_id is the // only key the caller has; we must read the row to know the scope. package tools import ( "context" "errors" "fmt" "gitea.stevedudenhoeffer.com/steve/executus/tool" ) type fileDeleteArgs struct { FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."` } // NewFileDelete constructs the file_delete tool. storage nil → "not // configured" at execute time. func NewFileDelete(storage FileStorage) tool.Tool { return tool.NewGatedTool[fileDeleteArgs]( "file_delete", "Remove a saved file by file_id. Returns 'ok' on success or 'not_found' if no file matched.", tool.Permission{ AuthoringRequirement: tool.RequirementAnyone, OperatesOn: tool.ScopeCaller, SafeForShare: true, Categories: []string{"storage", "write"}, }, func(ctx context.Context, inv tool.Invocation, args fileDeleteArgs) (string, error) { if storage == nil { return "", fmt.Errorf("file_delete: not configured") } if args.FileID == "" { return "", fmt.Errorf("file_delete: file_id required") } // Fetch first so we can validate scope before deleting. The // extra read is acceptable for a write path that's not in // the hot loop, and it preserves the cross-skill / // cross-user safety story. meta, _, err := storage.FileGet(ctx, args.FileID) if err != nil { if errors.Is(err, ErrFileNotFound) { return "not_found", nil } return "", fmt.Errorf("file_delete: %w", err) } // Honor the descendant grant like the read tools do, so a parent // orchestrator can clean up a worker's artifacts (gadfly flagged the // asymmetry: delete previously rejected cross-skill outright). grantedViaDescendant := false if meta.SkillID != inv.SkillID { if !descendantFileGrant(ctx, storage, inv, meta.SkillID) { return "", fmt.Errorf("file_delete: file does not belong to this skill") } grantedViaDescendant = true } if !grantedViaDescendant { if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil { return "", fmt.Errorf("file_delete: %w", err) } } if err := storage.FileDelete(ctx, args.FileID); err != nil { if errors.Is(err, ErrFileNotFound) { // Race: row was deleted between FileGet and // FileDelete. Surface as a clean miss. return "not_found", nil } return "", fmt.Errorf("file_delete: %w", err) } return "ok", nil }, ) }