Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/dashboard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ jobs:
bun-version: latest
- run: bun install
- run: bun run check
# The open-redirect guard on ?next= is security-critical and only provable by test:
# a comment claiming a value is validated is not evidence. Runs before build so a
# regression fails fast.
- run: bun run test
- run: bun run build
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<div align="center">

<img src="./docs/assets/logo.png" alt="agentdraft" width="88" height="88">

# agentdraft

**Publish AI-agent plans to stable, versioned review URLs — HTML and Markdown, one command.**

Open source by **[Tapetide](https://tapetide.com/)** · MIT licensed · runs on Cloudflare's free tier

[Quick start](#quick-start) · [How it works](#how-it-works) · [CLI](./docs/cli.md) · [API](./docs/api.md) · [Security model](./SECURITY.md) · [Self-hosting](./docs/self-hosting.md)
[Quick start](#quick-start) · [How it works](#how-it-works) · [CLI](./docs/cli.md) · [API](./docs/api.md) · [Agent skills](./skills/) · [Security model](./SECURITY.md) · [Self-hosting](./docs/self-hosting.md)

</div>

Expand Down Expand Up @@ -108,6 +110,26 @@ The CLI records which draft a local file maps to, so re-running `upload` on the
updates the same URL instead of littering new ones. Git branch, commit SHA and dirty
state are captured automatically when you publish from inside a repository.

### Public or private links

Drafts are public by default — anyone with the URL reads them, no sign-in. Flip one to
private and the URL is unchanged, but only you can open it:

```bash
agentdraft upload plan.md --private # publish it owner-only
agentdraft visibility private plan.md # flip an existing draft
agentdraft visibility private --default # default for NEW drafts
agentdraft visibility public --all # every existing draft
```

A private link still works for you: it routes through the dashboard, which verifies you own
the draft. Anyone else lands on sign-in and is returned to that exact document afterwards.
The account default and a per-draft toggle are also in the dashboard under **Privacy**.

Changing the default affects **new drafts only** — links you already shared with reviewers
are never silently privatised. `--all` is the explicit opt-in and reports how many drafts
actually changed.

### Security model for untrusted HTML

Uploaded documents are treated as hostile, because they are:
Expand Down
40 changes: 40 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,46 @@ KV-backed fixed-window counters, keyed on the API key (or IP for unauthenticated
uploads 100/hour, reads 1000/hour. Fixed windows are coarse but adequate for abuse
prevention on the free tier without Durable Objects.

## Draft visibility (private drafts)

A draft is `public` (anyone with the URL) or `private` (owner only). Public is the default.

**Entropy is not the control.** Draft ids are 22 chars of base36 (~113 bits; legacy ids are
12 chars / ~62 bits and keep working). Even 62 bits was not enumerable in practice —
roughly 7.5 years to a first hit at 100k req/s against 100k drafts, before rate limiting.
The real exposure was never guessing: it was that URLs **leak** (chat, PR comments, CI
logs, `Referer` headers) and, once leaked, granted permanent unrevocable read. Access
control fixes that; entropy does not. Treat the id length as defence in depth only.

**How a private draft is served.** The content origin is cookie-free by design, because it
serves attacker-controlled HTML — a session readable there would be reachable by a CSP
bypass in a published document. It therefore cannot authorize a viewer, and does not try:
it `302`s to the dashboard, which holds the session, verifies ownership, and streams the
bytes on its own origin under the same CSP (including a `sandbox` directive in the header,
so a direct navigation is sandboxed even with no embedding iframe) plus
`Cache-Control: private, no-store`.

That means a private draft renders on the **dashboard** origin, which is the property the
two-origin split otherwise avoids. It is accepted deliberately and mitigated by the
header-level `sandbox` + `script-src 'none'`; public drafts keep the stricter isolated path
unchanged.

**Existence is not disclosed.** A non-owner receives `404` on every visibility and content
endpoint, never `403`. The content origin's `302` does reveal that *some* draft exists at a
guessed id — an existence oracle that is not exploitable at 113 bits, and collapsing it
into `404` would make an owner's typo indistinguishable from someone else's private draft.

**Changing the account default is not retroactive.** It applies to new drafts only.
Silently privatising links already handed to reviewers is the one behaviour a preference
must not have, so existing drafts change only through an explicit bulk action that reports
how many rows it touched.

**Return-to after sign-in is allow-listed.** A signed-out viewer of a private draft is sent
to `/login?next=…` and returned to that document. `next` is validated against a path
allow-list (`safeNext`), not merely checked for a leading `/`: scheme-relative `//evil`,
backslash, percent-encoded and control-character variants are all rejected, or the sign-in
page becomes an open redirect wearing our domain.

## What v1 does NOT include

- **No anonymous uploads.** Publishing requires an owner-tied, revocable key. Public
Expand Down
12 changes: 11 additions & 1 deletion cli/cmd/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"

"github.com/Tapetide-hq/agentdraft/cli/internal/api"
"github.com/Tapetide-hq/agentdraft/cli/internal/auth"
"github.com/Tapetide-hq/agentdraft/cli/internal/config"
)

Expand All @@ -26,7 +27,16 @@ var fetchCmd = &cobra.Command{
if err != nil {
return err
}
client := api.New(cfg.APIURL, "")
// Pass the stored key when there is one. Public drafts do not need it, but a
// PRIVATE draft answers the content origin with a 302 and the client falls back
// to the authenticated API path — which is impossible with an empty key. Missing
// credentials are not an error here: fetching a public draft must keep working
// for an unauthenticated user.
key := ""
if creds, err := auth.Load(); err == nil {
key = creds.APIKey
}
client := api.New(cfg.APIURL, key)

target := args[0]
rawURL := resolveFetchURL(target, fetchVersion)
Expand Down
11 changes: 9 additions & 2 deletions cli/cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,16 @@ var listCmd = &cobra.Command{
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "DRAFT ID\tVER\tTITLE\tURL")
// VISIBILITY is shown because a private draft's URL looks identical to a public
// one. Without this column the only way to tell them apart is to open the link
// in a logged-out browser, which is exactly the mistake this column prevents.
fmt.Fprintln(w, "DRAFT ID VER VISIBILITY TITLE URL")
for _, d := range resp.Drafts {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", d.ID, d.PublishedVersion, truncate(d.Title, 30), d.PublicURL)
vis := "public"
if d.IsPublic == 0 {
vis = "private"
}
fmt.Fprintf(w, "%s %d %s %s %s\n", d.ID, d.PublishedVersion, vis, truncate(d.Title, 30), d.PublicURL)
}
return w.Flush()
},
Expand Down
22 changes: 22 additions & 0 deletions cli/cmd/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
upDescription string
upTitle string
upIdempotency string
upPrivate bool
upPublic bool
)

var uploadCmd = &cobra.Command{
Expand Down Expand Up @@ -97,6 +99,21 @@ var uploadCmd = &cobra.Command{
},
}

// Visibility override for a NEW draft. Sent as a POINTER so "flag absent" is
// distinguishable from "explicitly public": omitting it lets the account default
// apply, which is what an agent with no opinion should get.
//
// Note this only affects draft CREATION. Re-uploading to an existing draft never
// changes its visibility — silently flipping a shared link on the next `upload`
// would be a nasty surprise. Use `agentdraft visibility` for that.
if upPrivate {
v := false
req.Public = &v
} else if upPublic {
v := true
req.Public = &v
}

resp, err := client.Upload(req, upIdempotency)
if err != nil {
return err
Expand Down Expand Up @@ -164,5 +181,10 @@ func init() {
uploadCmd.Flags().StringVar(&upDescription, "description", "", "short label for the draft")
uploadCmd.Flags().StringVar(&upTitle, "title", "", "override the extracted title")
uploadCmd.Flags().StringVar(&upIdempotency, "idempotency-key", "", "dedupe retries")
uploadCmd.Flags().BoolVar(&upPrivate, "private", false, "create the draft private (owner-only); overrides the account default")
uploadCmd.Flags().BoolVar(&upPublic, "public", false, "create the draft public; overrides the account default")
// Mutually exclusive: asking for both is a mistake worth surfacing rather than
// silently letting one win.
uploadCmd.MarkFlagsMutuallyExclusive("private", "public")
rootCmd.AddCommand(uploadCmd)
}
150 changes: 150 additions & 0 deletions cli/cmd/visibility.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"regexp"

"github.com/spf13/cobra"

"github.com/Tapetide-hq/agentdraft/cli/internal/api"
"github.com/Tapetide-hq/agentdraft/cli/internal/auth"
"github.com/Tapetide-hq/agentdraft/cli/internal/config"
"github.com/Tapetide-hq/agentdraft/cli/internal/drafts"
)

var (
visAll bool
visDefault bool
)

// contentPathRe pulls a draft id out of a full content URL.
// 6..32 chars so both legacy 12-char ids and current 22-char ids resolve.
var contentPathRe = regexp.MustCompile(`/d/([a-z0-9]{6,32})`)

// absOrSelf resolves a path for the drafts.json lookup, which is keyed on ABSOLUTE
// paths. Falls back to the input unchanged when it is not a usable path, so a bare id
// or URL passes through untouched instead of erroring.
func absOrSelf(p string) string {
abs, err := filepath.Abs(p)
if err != nil {
return p
}
return abs
}

// resolveDraftArg turns a user-supplied argument into a draft id.
//
// Accepts a bare id, a full content URL, or a local file path that has been uploaded
// before (resolved through the same ~/.agentdraft/drafts.json mapping `upload` uses).
// Accepting the file path matters: the user's mental model is "the plan I published",
// not a 22-char opaque id they never chose.
func resolveDraftArg(arg string) string {
if id := drafts.Get(absOrSelf(arg)); id != "" {
return id
}
if m := contentPathRe.FindStringSubmatch(arg); m != nil {
return m[1]
}
return arg
}

var visibilityCmd = &cobra.Command{
Use: "visibility <public|private> [draft-id|url|file]",
Short: "Make a draft public or private",
Long: `Change who can read a draft.

public anyone with the URL can read it
private only you, signed in via the dashboard or this CLI

The URL never changes. A private draft's link still works for you — it redirects
through the dashboard, which verifies you own it. Anyone else gets a sign-in page.

agentdraft visibility private plan.md # the draft this file publishes to
agentdraft visibility private a1b2c3d4e5f6 # by id
agentdraft visibility public --all # every draft in the account
agentdraft visibility private --default # default for NEW drafts only`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
var public bool
switch args[0] {
case "public":
public = true
case "private":
public = false
default:
return fmt.Errorf("first argument must be 'public' or 'private', got %q", args[0])
}

cfg, err := config.Load()
if err != nil {
return err
}
creds, err := auth.Load()
if err != nil {
return err
}
client := api.New(cfg.APIURL, creds.APIKey)

switch {
case visDefault:
// Account default. Deliberately does NOT touch existing drafts — see --all.
res, err := client.SetDefaultVisibility(public)
if err != nil {
return err
}
state := "private"
if res.DefaultDraftPublic {
state = "public"
}
fmt.Printf("New drafts will be %s.\n", state)
fmt.Println("Existing drafts are unchanged. Use --all to change those too.")
return nil

case visAll:
res, err := client.SetAllDraftsVisibility(public)
if err != nil {
return err
}
state := "private"
if res.Public {
state = "public"
}
// Report the real count. "Done" without a number hides a no-op.
switch res.Changed {
case 0:
fmt.Printf("No changes — every draft was already %s.\n", state)
case 1:
fmt.Printf("1 draft is now %s.\n", state)
default:
fmt.Printf("%d drafts are now %s.\n", res.Changed, state)
}
return nil

default:
if len(args) < 2 {
return fmt.Errorf("specify a draft (id, url, or file), or pass --all / --default")
}
id := resolveDraftArg(args[1])
res, err := client.SetDraftVisibility(id, public)
if err != nil {
return err
}
if res.Public {
fmt.Printf("Public — anyone with the link can read it.\n%s\n", res.PublicURL)
} else {
fmt.Printf("Private — only you can read it, signed in.\n%s\n", res.PublicURL)
fmt.Fprintln(os.Stderr, "The link is unchanged; others now get a sign-in page.")
}
return nil
}
},
}

func init() {
visibilityCmd.Flags().BoolVar(&visAll, "all", false, "apply to EVERY existing draft in the account")
visibilityCmd.Flags().BoolVar(&visDefault, "default", false, "set the default for NEW drafts (existing drafts untouched)")
visibilityCmd.MarkFlagsMutuallyExclusive("all", "default")
rootCmd.AddCommand(visibilityCmd)
}
7 changes: 7 additions & 0 deletions cli/cmd/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ var whoamiCmd = &cobra.Command{
}
fmt.Printf("Auth: %s\n", me.Via)
fmt.Printf("Scopes: %s\n", strings.Join(me.Scopes, ", "))
// Surface the visibility default so a user can see, without guessing, whether
// their next `upload` will produce a public or a private link.
if me.Account.DefaultDraftPublic {
fmt.Printf("New drafts: public\n")
} else {
fmt.Printf("New drafts: private (only you can read them)\n")
}
return nil
},
}
Expand Down
Loading