Skip to content

Commit 47d2dc8

Browse files
committed
feat: add internal secret helper
1 parent d3ca6b7 commit 47d2dc8

File tree

7 files changed

+419
-0
lines changed

7 files changed

+419
-0
lines changed

cmd/gh-aw/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
494494
mcpGatewayCmd := cli.NewMCPGatewayCommand()
495495
prCmd := cli.NewPRCommand()
496496
campaignCmd := campaign.NewCommand()
497+
tokensCmd := cli.NewTokensCommand()
497498

498499
// Assign commands to groups
499500
// Setup Commands
@@ -502,6 +503,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
502503
addCmd.GroupID = "setup"
503504
removeCmd.GroupID = "setup"
504505
updateCmd.GroupID = "setup"
506+
tokensCmd.GroupID = "setup"
505507

506508
// Development Commands
507509
compileCmd.GroupID = "development"
@@ -546,6 +548,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
546548
rootCmd.AddCommand(prCmd)
547549
rootCmd.AddCommand(versionCmd)
548550
rootCmd.AddCommand(campaignCmd)
551+
rootCmd.AddCommand(tokensCmd)
549552
}
550553

551554
func main() {

docs/src/content/docs/reference/tokens.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,50 @@ sidebar:
77

88
GitHub Agentic Workflows authenticate using multiple tokens depending on the operation. This reference explains which token to use, when it's required, and how precedence works across different operations.
99

10+
## Quick start: tokens you actually configure
11+
12+
GitHub Actions always provides `GITHUB_TOKEN` for you automatically.
13+
For GitHub Agentic Workflows, you only need to create a few **optional** secrets in your own repo:
14+
15+
| When you need this… | Secret to create | Notes |
16+
|------------------------------------------------------|----------------------------------------|-------|
17+
| Cross-repo Project Ops / remote GitHub tools | `GH_AW_GITHUB_TOKEN` | PAT or app token with cross-repo access. |
18+
| Copilot workflows (CLI, engine, agent tasks, etc.) | `COPILOT_GITHUB_TOKEN` | Needs Copilot Requests permission and repo access. |
19+
| Assigning agents/bots to issues or pull requests | `GH_AW_AGENT_TOKEN` | Used by `assign-to-agent` and Copilot assignee/reviewer flows. |
20+
| Isolating MCP server permissions (advanced optional) | `GH_AW_GITHUB_MCP_SERVER_TOKEN` | Only if you want MCP to use a different token than other jobs. |
21+
22+
Create these as **repository or organization secrets in *your* repo**, for example with the GitHub CLI:
23+
24+
```bash
25+
gh secret set GH_AW_GITHUB_TOKEN -a actions --body "YOUR_PAT"
26+
gh secret set COPILOT_GITHUB_TOKEN -a actions --body "YOUR_COPILOT_PAT"
27+
gh secret set GH_AW_AGENT_TOKEN -a actions --body "YOUR_AGENT_PAT"
28+
```
29+
30+
After these are set, gh-aw will automatically pick the right token for each operation; you should not need per-workflow PATs in most cases.
31+
32+
### Security and scopes (least privilege)
33+
34+
- Use `permissions:` at the workflow or job level so `GITHUB_TOKEN` only has what that workflow needs (for example, read contents and write PRs, but nothing else):
35+
36+
```yaml
37+
permissions:
38+
contents: read
39+
pull-requests: write
40+
```
41+
42+
- When creating each PAT/App token above, grant access **only** to the repos and scopes required for its scenario (cross-repo Project Ops, Copilot, agents, or MCP) and nothing more.
43+
- Only expose powerful secrets to the jobs that need them by scoping them to `env:` at the job or step level, not globally:
44+
45+
```yaml
46+
jobs:
47+
project-ops:
48+
env:
49+
GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
50+
```
51+
52+
- For very sensitive tokens, prefer GitHub Environments or organization-level secrets with required reviewers so only trusted workflows can use them.
53+
1054
## Token Overview
1155

1256
| Token | Type | Purpose | User Configurable |

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/spf13/cobra v1.10.2
1818
github.com/stretchr/testify v1.11.1
1919
github.com/xeipuuv/gojsonschema v1.2.0
20+
golang.org/x/crypto v0.36.0
2021
golang.org/x/term v0.38.0
2122
gopkg.in/yaml.v3 v3.0.1
2223
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
139139
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
140140
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
141141
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
142+
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
143+
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
142144
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
143145
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
144146
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=

internal/tools/ghsecret/main.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"crypto/rand"
6+
"encoding/base64"
7+
"encoding/json"
8+
"errors"
9+
"flag"
10+
"fmt"
11+
"io"
12+
"log"
13+
"net/http"
14+
"net/url"
15+
"os"
16+
"strings"
17+
18+
"golang.org/x/crypto/nacl/box"
19+
)
20+
21+
type repoPublicKey struct {
22+
ID string `json:"key_id"`
23+
Key string `json:"key"`
24+
}
25+
26+
type secretPayload struct {
27+
EncryptedValue string `json:"encrypted_value"`
28+
KeyID string `json:"key_id"`
29+
}
30+
31+
func main() {
32+
var (
33+
flagOwner = flag.String("owner", "", "GitHub repository owner or organization")
34+
flagRepo = flag.String("repo", "", "GitHub repository name")
35+
flagSecretName = flag.String("secret", "", "Secret name to create or update")
36+
flagValue = flag.String("value", "", "Secret value (if empty, read from stdin)")
37+
flagValueEnv = flag.String("value-from-env", "", "Environment variable to read secret value from")
38+
flagAPIBase = flag.String("api-url", "", "GitHub API base URL (default: https://api.github.com or $GITHUB_API_URL)")
39+
)
40+
41+
flag.Parse()
42+
43+
if *flagOwner == "" || *flagRepo == "" || *flagSecretName == "" {
44+
flag.Usage()
45+
os.Exit(1)
46+
}
47+
48+
apiBase := resolveAPIBase(*flagAPIBase)
49+
token, err := resolveToken()
50+
if err != nil {
51+
log.Fatalf("cannot resolve GitHub token: %v", err)
52+
}
53+
54+
secretValue, err := resolveSecretValue(*flagValueEnv, *flagValue)
55+
if err != nil {
56+
log.Fatalf("cannot resolve secret value: %v", err)
57+
}
58+
59+
if err := setRepoSecret(apiBase, token, *flagOwner, *flagRepo, *flagSecretName, secretValue); err != nil {
60+
log.Fatalf("failed to set secret: %v", err)
61+
}
62+
63+
fmt.Printf("Secret %s updated for %s/%s\n", *flagSecretName, *flagOwner, *flagRepo)
64+
}
65+
66+
func resolveAPIBase(flagValue string) string {
67+
candidates := []string{
68+
strings.TrimSpace(flagValue),
69+
strings.TrimSpace(os.Getenv("GITHUB_API_URL")),
70+
}
71+
72+
for _, c := range candidates {
73+
if c != "" {
74+
return strings.TrimRight(c, "/")
75+
}
76+
}
77+
78+
return "https://api.github.com"
79+
}
80+
81+
func resolveToken() (string, error) {
82+
for _, name := range []string{"GITHUB_TOKEN", "GH_TOKEN"} {
83+
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
84+
return v, nil
85+
}
86+
}
87+
return "", errors.New("no token found; set GITHUB_TOKEN or GH_TOKEN")
88+
}
89+
90+
func resolveSecretValue(fromEnv, fromFlag string) (string, error) {
91+
if fromEnv != "" {
92+
v := os.Getenv(fromEnv)
93+
if v == "" {
94+
return "", fmt.Errorf("environment variable %s is not set or empty", fromEnv)
95+
}
96+
return v, nil
97+
}
98+
99+
if fromFlag != "" {
100+
return fromFlag, nil
101+
}
102+
103+
info, err := os.Stdin.Stat()
104+
if err != nil {
105+
return "", err
106+
}
107+
108+
if info.Mode()&os.ModeCharDevice != 0 {
109+
fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:")
110+
}
111+
112+
reader := bufio.NewReader(os.Stdin)
113+
var b strings.Builder
114+
115+
for {
116+
line, err := reader.ReadString('\n')
117+
b.WriteString(line)
118+
if err != nil {
119+
if errors.Is(err, io.EOF) {
120+
break
121+
}
122+
return "", err
123+
}
124+
}
125+
126+
value := strings.TrimRight(b.String(), "\r\n")
127+
if value == "" {
128+
return "", errors.New("secret value is empty")
129+
}
130+
return value, nil
131+
}
132+
133+
func setRepoSecret(apiBase, token, owner, repo, name, value string) error {
134+
pubKey, err := getRepoPublicKey(apiBase, token, owner, repo)
135+
if err != nil {
136+
return fmt.Errorf("get repo public key: %w", err)
137+
}
138+
139+
encrypted, err := encryptWithPublicKey(pubKey.Key, value)
140+
if err != nil {
141+
return fmt.Errorf("encrypt secret: %w", err)
142+
}
143+
144+
return putRepoSecret(apiBase, token, owner, repo, name, pubKey.ID, encrypted)
145+
}
146+
147+
func getRepoPublicKey(apiBase, token, owner, repo string) (*repoPublicKey, error) {
148+
endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/public-key", apiBase, owner, repo)
149+
150+
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
151+
if err != nil {
152+
return nil, err
153+
}
154+
addGitHubHeaders(req, token)
155+
156+
resp, err := http.DefaultClient.Do(req)
157+
if err != nil {
158+
return nil, err
159+
}
160+
defer resp.Body.Close()
161+
162+
if resp.StatusCode != http.StatusOK {
163+
body, _ := io.ReadAll(resp.Body)
164+
return nil, fmt.Errorf("GitHub API %s: %s", resp.Status, string(body))
165+
}
166+
167+
var key repoPublicKey
168+
if err := json.NewDecoder(resp.Body).Decode(&key); err != nil {
169+
return nil, err
170+
}
171+
if key.ID == "" || key.Key == "" {
172+
return nil, errors.New("public key response missing key_id or key")
173+
}
174+
return &key, nil
175+
}
176+
177+
func encryptWithPublicKey(publicKeyB64, plaintext string) (string, error) {
178+
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
179+
if err != nil {
180+
return "", fmt.Errorf("decode public key: %w", err)
181+
}
182+
if len(raw) != 32 {
183+
return "", fmt.Errorf("unexpected public key length: %d", len(raw))
184+
}
185+
186+
var pk [32]byte
187+
copy(pk[:], raw)
188+
189+
ciphertext, err := box.SealAnonymous(nil, []byte(plaintext), &pk, rand.Reader)
190+
if err != nil {
191+
return "", fmt.Errorf("nacl encryption failed: %w", err)
192+
}
193+
194+
return base64.StdEncoding.EncodeToString(ciphertext), nil
195+
}
196+
197+
func putRepoSecret(apiBase, token, owner, repo, name, keyID, encryptedValue string) error {
198+
endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s",
199+
apiBase, owner, repo, url.PathEscape(name))
200+
201+
body, err := json.Marshal(secretPayload{
202+
EncryptedValue: encryptedValue,
203+
KeyID: keyID,
204+
})
205+
if err != nil {
206+
return err
207+
}
208+
209+
req, err := http.NewRequest(http.MethodPut, endpoint, strings.NewReader(string(body)))
210+
if err != nil {
211+
return err
212+
}
213+
addGitHubHeaders(req, token)
214+
req.Header.Set("Content-Type", "application/json")
215+
216+
resp, err := http.DefaultClient.Do(req)
217+
if err != nil {
218+
return err
219+
}
220+
defer resp.Body.Close()
221+
222+
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
223+
b, _ := io.ReadAll(resp.Body)
224+
return fmt.Errorf("GitHub API %s: %s", resp.Status, string(b))
225+
}
226+
227+
return nil
228+
}
229+
230+
func addGitHubHeaders(req *http.Request, token string) {
231+
req.Header.Set("Accept", "application/vnd.github+json")
232+
req.Header.Set("Authorization", "Bearer "+token)
233+
if req.Header.Get("X-GitHub-Api-Version") == "" {
234+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
235+
}
236+
}

pkg/cli/tokens.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package cli
2+
3+
import (
4+
"github.com/githubnext/gh-aw/pkg/logger"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
var tokensCommandLog = logger.New("cli:tokens")
9+
10+
// NewTokensCommand creates the main tokens command with subcommands
11+
func NewTokensCommand() *cobra.Command {
12+
tokensCommandLog.Print("Creating tokens command with subcommands")
13+
cmd := &cobra.Command{
14+
Use: "tokens",
15+
Short: "Inspect and bootstrap GitHub tokens for gh-aw",
16+
Long: `Token utilities for GitHub Agentic Workflows.
17+
18+
Use this command to check which recommended secrets are configured
19+
for the current repository and to see how to create them with
20+
minimum required permissions.`,
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
return cmd.Help()
23+
},
24+
}
25+
26+
// Add subcommands
27+
cmd.AddCommand(NewTokensBootstrapSubcommand())
28+
29+
return cmd
30+
}

0 commit comments

Comments
 (0)