// file_get fetches a previously-saved file by its opaque file_id and // returns the metadata + base64-encoded bytes. // // Why scope is checked POST-fetch: file_id is the only key the caller // knows; the scope (and therefore the authorisation envelope) is // stored on the FileMeta row. We must read the row first to know which // scope to validate. The trade-off is that file_id existence is // observable (a foreign caller can probe IDs and learn that one // exists), but the bytes themselves are still gated. file_id is a UUID, // so the probe surface is impractical. // // Why base64 in the response: same reason as file_save — JSON can't // carry arbitrary bytes natively. Callers that want a paste link or a // direct download go through a separate path. package tools import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "time" "gitea.stevedudenhoeffer.com/steve/executus/tool" ) type fileGetArgs struct { FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."` } type fileGetResult struct { Name string `json:"name"` ContentBase64 string `json:"content_base64"` Mime string `json:"mime"` SizeBytes int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` // RFC3339 } // NewFileGet constructs the file_get tool. storage nil → "not // configured" at execute time. func NewFileGet(storage FileStorage) tool.Tool { return tool.NewGatedTool[fileGetArgs]( "file_get", "Fetch a saved file by its file_id. Returns name, base64 content, MIME, size, and created_at. The caller must have access to the file's scope (skill / own user: / own run:).", tool.Permission{ AuthoringRequirement: tool.RequirementAnyone, OperatesOn: tool.ScopeCaller, SafeForShare: true, Categories: []string{"storage", "read"}, }, func(ctx context.Context, inv tool.Invocation, args fileGetArgs) (string, error) { if storage == nil { return "", fmt.Errorf("file_get: not configured") } if args.FileID == "" { return "", fmt.Errorf("file_get: file_id required") } meta, content, err := storage.FileGet(ctx, args.FileID) if err != nil { if errors.Is(err, ErrFileNotFound) { return "", fmt.Errorf("file_get: not found") } return "", fmt.Errorf("file_get: %w", err) } // Cross-skill access check: a file's SkillID must match the // current invocation's SkillID. Without this, a caller // could probe another skill's file_ids and read content. // One exception — the descendant grant (see // file_descendant_grant.go): workers this run dispatched. grantedViaDescendant := false if meta.SkillID != inv.SkillID { if !descendantFileGrant(ctx, storage, inv, meta.SkillID) { return "", fmt.Errorf("file_get: file does not belong to this skill") } grantedViaDescendant = true } // Scope check: even within the same skill, the scope on the // row gates access (e.g. user:bob's file is unreadable by // alice). The descendant grant stands in for it — the file's // scope is the WORKER's run, never the caller's. if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil && !grantedViaDescendant { return "", fmt.Errorf("file_get: %w", err) } res := fileGetResult{ Name: meta.Name, ContentBase64: base64.StdEncoding.EncodeToString(content), Mime: meta.MimeType, SizeBytes: meta.SizeBytes, CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339), } b, err := json.Marshal(res) if err != nil { return "", fmt.Errorf("file_get: marshal: %w", err) } return string(b), nil }, ) }