// create_file_url mints a public-token URL (mort.sh/files/) // that resolves to a saved file_id. Use it for artifacts that are too // large for Discord (>25 MiB), need a stable link to share outside // Discord, or where the recipient is not in mort's auth domain. // // Why a separate tool (vs always returning a URL from file_save): // most files are private working state — only some need a public URL, // and minting one is a deliberate act. Decoupling save from // publication keeps the storage layer cheap (no token row per file) // and the audit clean (you can grep skill_file_tokens for "who // published what"). // // Cycle-break: this tool can't import pkg/logic/skills directly // (pkg/logic/skills imports pkg/skilltools). The narrow interface // FileTokenMinter is declared here; mort.go bridges to // *skills.System.Storage() at wiring time. package tools import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "strings" "time" "gitea.stevedudenhoeffer.com/steve/executus/tool" ) // FileToken is the wire-shape of the storage row that backs the // public /files/ URL. Mirrors pkg/logic/skills.FileToken // field-for-field; the adapter in mort.go is a struct copy. // // Why mirror (vs import skills.FileToken): same cycle constraint as // FileDomainMeta / KVDomainEntry — the tool layer cannot import // pkg/logic/skills. type FileToken struct { Token string FileID string SkillID string CallerID string CreatedAt time.Time ExpiresAt *time.Time MaxViews *int Views int } // FileTokenMinter is the narrow interface the create_file_url tool // needs to persist a new token. Production wires to // *skills.gormStorage via a thin adapter in mort.go. type FileTokenMinter interface { SaveFileToken(ctx context.Context, t FileToken) error } // Caps for create_file_url. Public so tests can assert against them. const ( // DefaultFileURLExpiry is the default lifetime applied when the // caller doesn't supply expires_in_seconds. DefaultFileURLExpiry = 24 * time.Hour // MaxFileURLExpiry is the per-tool hard cap. 30 days is generous // enough for "share this report with someone" without becoming // effectively-permanent. Operators can lower via the // SkillFileURLConfigProvider; this is the floor below which the // admin gate doesn't apply. MaxFileURLExpiry = 30 * 24 * time.Hour // MaxFileURLViews is the per-tool hard cap on max_views. 1000 is // the largest value an LLM might plausibly set; anything beyond // is "unlimited" semantically and the caller should leave the // field absent. MaxFileURLViews = 1000 ) type createFileURLArgs struct { FileID string `json:"file_id" description:"file_id previously saved by this skill (from file_save, code_exec, etc)."` ExpiresInSeconds int `json:"expires_in_seconds,omitempty" description:"How long the URL stays valid in seconds. Default 86400 (24h). Max 2592000 (30 days)."` MaxViews int `json:"max_views,omitempty" description:"Optional cap on the number of times the URL can be fetched. Max 1000. Omit (or 0) for unlimited within the lifetime."` } type createFileURLResult struct { URL string `json:"url"` Token string `json:"token"` ExpiresAt string `json:"expires_at,omitempty"` // RFC3339 MaxViews int `json:"max_views,omitempty"` Note string `json:"note,omitempty"` } // NewCreateFileURL constructs the create_file_url tool. nil minter → // "not configured" at execute time; nil fileStorage same. baseURL is // the public site (e.g. "https://mort.sh"); the path "/files/" // is appended. // // Permission shape: anyone-authoring + caller-scope + share-safe + // files/discord/composition. The "publishing" act is a tool call, // not a save-time / share-time concern — every caller of a shared // skill mints into their own audit trail. func NewCreateFileURL(minter FileTokenMinter, fileStorage FileStorage, baseURL string) tool.Tool { baseURL = strings.TrimRight(baseURL, "/") return tool.NewGatedTool[createFileURLArgs]( "create_file_url", "Mint a public URL (mort.sh/files/) for a saved file_id. Use for files too large for Discord (>25 MiB) or when a stable link is preferred over an attachment. Default expiry 24h; max 30 days. Optional view-count cap (max 1000).", tool.Permission{ AuthoringRequirement: tool.RequirementAnyone, OperatesOn: tool.ScopeCaller, SafeForShare: true, Categories: []string{"files", "discord"}, }, func(ctx context.Context, inv tool.Invocation, args createFileURLArgs) (string, error) { if minter == nil || fileStorage == nil { return "", fmt.Errorf("create_file_url: not configured") } if strings.TrimSpace(args.FileID) == "" { return "", fmt.Errorf("create_file_url: file_id required") } // Cross-skill rejection: the file MUST belong to the // calling skill. Without this, a hostile skill could mint // a URL for ANY file by file_id. meta, _, err := fileStorage.FileGet(ctx, args.FileID) if err != nil { if errors.Is(err, ErrFileNotFound) { return "", fmt.Errorf("create_file_url: file_id %q not found", args.FileID) } return "", fmt.Errorf("create_file_url: %w", err) } if meta.SkillID != inv.SkillID && !descendantFileGrant(ctx, fileStorage, inv, meta.SkillID) { return "", fmt.Errorf("create_file_url: file_id %q does not belong to this skill (cross-skill refs rejected)", args.FileID) } // Resolve expiry. expiry := DefaultFileURLExpiry if args.ExpiresInSeconds > 0 { expiry = time.Duration(args.ExpiresInSeconds) * time.Second } if expiry > MaxFileURLExpiry { expiry = MaxFileURLExpiry } expiresAt := time.Now().Add(expiry) // Resolve max_views. var maxViews *int if args.MaxViews > 0 { mv := args.MaxViews if mv > MaxFileURLViews { mv = MaxFileURLViews } maxViews = &mv } // Mint a 32-byte random token, base64url-encoded // (padless). 43 chars long; the storage column is 64 so // there's room to grow without a migration. token, err := mintFileURLToken() if err != nil { return "", fmt.Errorf("create_file_url: token generation: %w", err) } // Persist. if err := minter.SaveFileToken(ctx, FileToken{ Token: token, FileID: args.FileID, SkillID: inv.SkillID, CallerID: inv.CallerID, ExpiresAt: &expiresAt, MaxViews: maxViews, }); err != nil { return "", fmt.Errorf("create_file_url: save: %w", err) } url := baseURL + "/files/" + token res := createFileURLResult{ URL: url, Token: token, ExpiresAt: expiresAt.UTC().Format(time.RFC3339), Note: "URL is public — anyone with the link can fetch this file until it expires or the view cap is reached.", } if maxViews != nil { res.MaxViews = *maxViews } b, _ := json.Marshal(res) return string(b), nil }, ) } // mintFileURLToken returns a 32-byte random token, base64url-encoded // without padding. ~190 bits of entropy, well above the // collision-resistance threshold for the 64-char storage column. func mintFileURLToken() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b[:]), nil }