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
55 changes: 44 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,27 +172,60 @@ nan runs reject <id> [--reason R] # turn the proposal

### Automations, approvals and secrets

Automations are created and edited in the portal. From the CLI you can list,
inspect and run them, read their webhook deliveries, see what waits for your
approval, and list your secrets (names and metadata, never values):
From the CLI you can create, change and run automations, manage their
triggers, read their webhook deliveries, see what waits for your approval,
and manage your secrets (values go in, only names and metadata come out):

```bash
nan automations ls [--json]
nan automations show <id|name> [--json]
nan automations create -f automation.yaml # YAML or JSON, - for stdin
nan automations edit <id|name> -f changes.yaml # only the fields in the file change
nan automations enable|disable <id|name>
nan automations rm <id|name> [--yes]
nan automations triggers ls <id|name> [--json]
nan automations triggers add <id|name> -f trigger.yaml [--json] # webhook URL + secret shown ONCE
nan automations triggers edit <id|name> <trigger-id> -f changes.yaml
nan automations triggers rotate <id|name> <trigger-id> [--url] [--secret] [--no-grace]
[--signing-secret-file PATH | --signing-secret-stdin] [--json]
nan automations triggers rm <id|name> <trigger-id> [--yes]
nan automations run <id|name> [--input @payload.json | --input - | --input JSON] [--detach] [--json]
nan automations deliveries <id|name> [--limit 50] [--json]
nan approvals ls [--state pending|approved|rejected|expired|cancelled|auto|all] [--limit 20] [--json]
nan approvals show <run-id> [--diff] # same as nan runs proposal
nan secrets ls [--json] # needs nan auth login
nan secrets ls [--json]
nan secrets show NAME [--json]
nan secrets set NAME (--value-file PATH | --value-stdin) [--kind provider_key --provider P --base-url URL] [--description D]
nan secrets edit NAME [--description D] [--base-url URL]
nan secrets rm NAME [--yes]
```

Approving a proposal, writing a secret and creating or editing an automation
happen only in the portal at <https://cloud.nan.builders>, signed in through
the browser. Each of them can hand a secret to an agent or let it act on your
repository without asking, so none is possible with a credential an agent or
a CI job could read: this machine's CLI session, a platform token or an API
key. A platform token needs the `automations:read` scope to list and inspect
automations and `automations:run` to start one.
A secret's value is never an argument (it would stay in your shell history):
it comes from `--value-file` or a pipe into `--value-stdin`, and is never
shown again. A webhook's URL and signing secret are printed once, to stdout,
when the trigger is added or rotated: store them in the sender right away.
A Stripe webhook is the exception: Stripe issues its signing secret, so it
goes in the `-f` file when you add the trigger (pipe the file with `-f -`),
and on rotate you roll it in the Stripe dashboard and pass the new `whsec_`
with `--signing-secret-file` or `--signing-secret-stdin`.

Writes need your session from `nan auth login`, or a platform token with the
scope of what it does: `secrets:read` to list secrets, `secrets:write` to
change them, `automations:write` to change automations (plus
`automations:read` to find one by name). Those scopes are added to a token in
the portal only, signed in through the browser. API keys (`sk-`) and
workspace keys never read or write secrets and never change automations.
Whoever holds a credential that can write automations can reach every secret
you have (an automation receives the secrets you assign it), so sign in on
your own machine, not inside a workspace, and give a workspace or CI job a
token with only the scopes it needs. If your account does not have the write
API yet, these commands exit 64 and point at the portal.

Approving a proposal stays portal-only, at <https://cloud.nan.builders>,
signed in through the browser: a credential an agent or a script can hold
never approves what an agent proposed. A platform token needs the
`automations:read` scope to list and inspect automations and
`automations:run` to start one.

The text views show workspace and automation names, looked up once per
command; detail views print `name (id)` so the id stays copyable. A token
Expand Down
57 changes: 27 additions & 30 deletions cmd/automations.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,51 +33,46 @@ var (
autoDeliveryJSON bool
)

// portalOnlyAutomationVerbs are the writes a member may reach for. Each one
// answers with where to do it instead of "unknown command".
var portalOnlyAutomationVerbs = map[string]bool{
"create": true, "new": true, "add": true, "edit": true, "update": true, "set": true,
"rm": true, "delete": true, "remove": true, "enable": true, "disable": true,
"triggers": true, "trigger": true, "rotate": true,
// automationVerbHints answer the verbs a member may reach for that are not
// commands, with the one that is.
var automationVerbHints = map[string]string{
"new": "create", "add": "create", "update": "edit", "set": "edit",
"rotate": "triggers rotate",
}

const automationsPortalOnly = "automations are created and edited in the portal: " + runs.AutomationsURL + " (new: " + runs.NewAutomationURL + ")" +
". An automation decides which of your secrets an agent receives and which of its actions need your approval, " +
"so writing one needs you signed in through the browser. From here you can list, inspect and run them " +
"(nan automations --help)"

var automationsCmd = &cobra.Command{
Use: "automations",
Short: "List, inspect and run your automations",
Long: `List, inspect and run the automations you set up in the portal, and read
their webhook delivery log.
Long: `Create, change, list, inspect and run your automations, manage their
triggers (webhooks, schedules), and read their webhook delivery log.

An automation is a saved agent task: a workspace, an agent, instructions,
the secrets it receives, and its triggers (webhooks, schedules). Its runs
can propose actions on your repository (a push, a pull request, a comment)
that apply automatically or wait for your approval, as you configured.

Portal-only, and why: creating and editing automations, writing secrets and
approving proposals need you signed in through the browser at
` + runs.PortalURL + `. Each of them can hand a secret to an agent
or let an agent act without asking, so none can be done with a credential an
agent or a CI job could read: this machine's CLI session, a platform token or
an API key.
Approving a proposal stays portal-only, signed in through the browser at
` + runs.PortalURL + `: a credential an agent or a script can hold
must never approve what an agent proposed.

Authentication of ls, show, run and deliveries is the same as nan run (see
nan run --help): a platform token needs the automations:read scope to list
and inspect, and automations:run to start a run. Workspace names are looked
up with workspaces:read; without it the workspace's id is shown instead.

Authentication is the same as nan run (see nan run --help). A platform token
needs the automations:read scope to list and inspect, and automations:run to
start a run. Workspace names are looked up with workspaces:read; without it
the workspace's id is shown instead.
Creating, changing and deleting automations and triggers:
` + writeCredentialHelp + `

Docs: https://nan.builders/docs/runs`,
Annotations: sysexits,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 && portalOnlyAutomationVerbs[args[0]] {
return usageErrorf("%s", automationsPortalOnly)
}
if len(args) > 0 {
return usageErrorf("unknown command %q for \"nan automations\"; try: nan automations --help", runs.Truncate(runs.SanitizeLine(args[0]), 64))
shown := runs.Truncate(runs.SanitizeLine(args[0]), 64)
if hint, ok := automationVerbHints[args[0]]; ok {
return usageErrorf("there is no nan automations %s; try: nan automations %s", shown, hint)
}
return usageErrorf("unknown command %q for \"nan automations\"; try: nan automations --help", shown)
}
return cmd.Help()
},
Expand Down Expand Up @@ -112,7 +107,7 @@ approval settings, and stream what it does.
--input passes a JSON object to the run: @FILE reads it from a file, -
from stdin, or give the JSON itself. The agent receives it as untrusted data
next to the automation's instructions, which it never replaces. Up to 48 KiB.
Do not put secrets in it: the agent reads it; assign secrets in the portal.
Do not put secrets in it: the agent reads it; assign secrets to the automation\ninstead (nan automations edit, or the portal).

When the run proposes actions that need your approval, it stops in
awaiting_approval and this command exits 10: read the proposal with nan runs
Expand Down Expand Up @@ -244,7 +239,7 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error {
return printJSON(env.stdout, list, true)
}
if len(list.Data) == 0 {
fmt.Fprintf(env.stderr, "No automations yet. Create one in the portal: %s\n", runs.NewAutomationURL)
fmt.Fprintf(env.stderr, "No automations yet. Create one with nan automations create -f FILE, or in the portal: %s\n", runs.NewAutomationURL)
return nil
}
for i := range list.Data {
Expand Down Expand Up @@ -355,6 +350,7 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error {
}
}
row("updated", localTime(a.UpdatedAt))
row("last changed from", updatedByLabel(a.UpdatedBy))
if err := tw.Flush(); err != nil {
return err
}
Expand All @@ -368,7 +364,8 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error {
}

printBlock(env, "instructions", a.Instructions)
fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nedit it in the portal: %s\n", runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID))
fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nchange it: nan automations edit %s -f FILE, or in the portal: %s\n",
runs.SanitizeLine(a.ID), runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID))
return nil
}

Expand Down
49 changes: 14 additions & 35 deletions cmd/automations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,19 +558,12 @@ func TestRunsLsAcceptsAwaitingApproval(t *testing.T) {
}
}

// nan secrets ls goes with the session even when a token is set: the
// platform refuses tokens and API keys there (§3.4).
func TestSecretsUseTheSessionOnly(t *testing.T) {
fakeHome(t, `{"token":"","platformToken":"`+testPAT+`","apiKey":"`+testKey+`"}`)
t.Setenv(tokenEnvVar, testPAT)
_, err := newSessionEnv()
if exitCode(err) != exitAuth || !strings.Contains(err.Error(), "nan auth login") || strings.Contains(err.Error(), testPAT) {
t.Fatalf("exit %d: %v", exitCode(err), err)
}

// nan secrets ls goes with the session even when a token is set, on the
// portal's route, which any session may read whatever the write API.
func TestSecretsLsPrefersTheSession(t *testing.T) {
fakeHome(t, `{"token":"sess-1","platformToken":"`+testPAT+`"}`)
t.Setenv(tokenEnvVar, testPAT)
env, err := newSessionEnv()
env, err := newWriteEnv()
if err != nil {
t.Fatal(err)
}
Expand All @@ -593,9 +586,6 @@ func TestSecretsUseTheSessionOnly(t *testing.T) {
}
}
assertNoControls(t, "secrets ls", out.String())
if !strings.Contains(errOut.String(), "Settings > Secrets") {
t.Errorf("no portal hint: %q", errOut.String())
}
}

func TestSecretsLsJSONHasNoValues(t *testing.T) {
Expand Down Expand Up @@ -642,20 +632,13 @@ func execute(t *testing.T, args ...string) (int, string) {
return code, msg
}

// Approving, writing secrets and writing automations are portal-only in
// Phase 2 (§9.4, §11.2, owner decision 10): no command does them, and
// reaching for one says where to go and why.
func TestPortalOnlyWritesPointToThePortal(t *testing.T) {
// Approving stays portal-only (write API decision 4): no command does it,
// and reaching for one says where to go and why.
func TestApprovingPointsToThePortal(t *testing.T) {
fakeHome(t, "")
for _, args := range [][]string{
{"runs", "approve", testRunID},
{"approvals", "approve", testRunID},
{"secrets", "set", "GITHUB_TOKEN"},
{"secrets", "rm", "GITHUB_TOKEN"},
{"automations", "create"},
{"automations", "edit", "PR review"},
{"automations", "rm", "PR review"},
{"automations", "triggers", "add"},
} {
code, msg := execute(t, args...)
if code != exitUsage || !strings.Contains(msg, "https://cloud.nan.builders") || !strings.Contains(msg, "browser") {
Expand All @@ -664,16 +647,12 @@ func TestPortalOnlyWritesPointToThePortal(t *testing.T) {
}
}

func TestNoCommandWrites(t *testing.T) {
func TestNoCommandApproves(t *testing.T) {
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, sub := range c.Commands() {
path := sub.CommandPath()
switch sub.Name() {
case "approve", "set", "create", "edit", "rm", "delete", "triggers":
if !strings.HasPrefix(path, "nan auth") {
t.Errorf("%q exists", path)
}
if sub.Name() == "approve" || sub.HasAlias("approve") {
t.Errorf("%q exists", sub.CommandPath())
}
walk(sub)
}
Expand Down Expand Up @@ -716,14 +695,14 @@ func TestPhase2Help(t *testing.T) {
if !strings.Contains(runCmd.Long, "10 awaiting approval") || !strings.Contains(runsLogsCmd.Long, "10 awaiting approval") {
t.Error("exit code 10 is not documented")
}
for _, c := range append(automationsCmd.Commands(), approvalsCmd.Commands()...) {
cmds := append(automationsCmd.Commands(), approvalsCmd.Commands()...)
cmds = append(cmds, secretsCmd.Commands()...)
cmds = append(cmds, automationsTriggersCmd.Commands()...)
for _, c := range cmds {
if c.InheritedFlags().Lookup("token-file") == nil {
t.Errorf("%s has no --token-file", c.CommandPath())
}
}
if secretsLsCmd.Flags().Lookup("token-file") != nil || secretsLsCmd.InheritedFlags().Lookup("token-file") != nil {
t.Error("nan secrets ls takes --token-file, which the platform would refuse")
}
}

// nan runs logs -f exits 10 on a run that stops for approval, like nan run.
Expand Down
Loading
Loading