Skip to content

Commit bb4f688

Browse files
authored
feat: inject keyring credentials for gh/glab profiles via secret-tool (#34)
On Linux, gh stores its OAuth token in gnome-keyring via libsecret. Since the D-Bus session bus is blocked inside the sandbox, gh cannot access the keyring directly. Instead, greywall reads the token on the host (before sandboxing) using secret-tool and injects it as GH_TOKEN. Changes: - Add KeyringSecrets field to AgentDef for profile-driven credential injection (envvar -> keyring service mapping) - Add ResolveKeyringSecrets() to read from host keyring via secret-tool - Wire up injection in main.go after env hardening, before exec - Add ~/.gitconfig to gh/glab toolchain profile (needed by gh) - Add secret-tool to greywall check with install suggestions - Add libsecret-tools to CI and install docs
1 parent a8bcc60 commit bb4f688

9 files changed

Lines changed: 161 additions & 8 deletions

File tree

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ jobs:
8686
bubblewrap \
8787
socat \
8888
xdg-dbus-proxy \
89+
libsecret-tools \
8990
uidmap \
9091
curl \
9192
netcat-openbsd \

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ make setup && make build
7878
- `bubblewrap` - container-free sandboxing (required)
7979
- `socat` - network bridging (required)
8080
- `xdg-dbus-proxy` - filtered D-Bus proxy for notify-send support (optional)
81+
- `libsecret-tools` - keyring credential injection for gh/glab (optional)
8182

8283
Check dependency status with `greywall check`.
8384

cmd/greywall/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,30 @@ func runCommand(cmd *cobra.Command, args []string) error {
363363
}
364364
}
365365

366+
// Inject keyring secrets for active profiles (Linux only).
367+
// This reads from the host keyring before sandboxing blocks D-Bus access.
368+
// Check the command itself and any explicitly loaded profiles.
369+
if !learning {
370+
profileNames := []string{cmdName}
371+
if profileName != "" {
372+
for _, name := range strings.Split(profileName, ",") {
373+
name = strings.TrimSpace(name)
374+
if name != "" {
375+
profileNames = append(profileNames, name)
376+
}
377+
}
378+
}
379+
for _, name := range profileNames {
380+
canonical := profiles.IsKnownAgent(name)
381+
if canonical == "" {
382+
continue
383+
}
384+
if secrets := profiles.GetKeyringSecrets(canonical); secrets != nil {
385+
hardenedEnv = append(hardenedEnv, profiles.ResolveKeyringSecrets(secrets, debug)...)
386+
}
387+
}
388+
}
389+
366390
execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional
367391
execCmd.Env = hardenedEnv
368392
execCmd.Stdin = os.Stdin

docs/quickstart.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ On Linux, you also need:
2323

2424
```bash
2525
# Ubuntu/Debian
26-
sudo apt install bubblewrap socat xdg-dbus-proxy
26+
sudo apt install bubblewrap socat xdg-dbus-proxy libsecret-tools
2727

2828
# Fedora
29-
sudo dnf install bubblewrap socat xdg-dbus-proxy
29+
sudo dnf install bubblewrap socat xdg-dbus-proxy libsecret
3030

3131
# Arch
32-
sudo pacman -S bubblewrap socat xdg-dbus-proxy
32+
sudo pacman -S bubblewrap socat xdg-dbus-proxy libsecret
3333
```
3434

35-
`xdg-dbus-proxy` is optional but recommended. It enables `notify-send` inside the sandbox while keeping the D-Bus session bus isolated.
35+
`xdg-dbus-proxy` is optional but recommended (enables `notify-send` inside the sandbox). `libsecret-tools` provides `secret-tool` for injecting keyring credentials (e.g., gh OAuth token) into the sandbox.
3636

3737
### Do I need sudo to run greywall?
3838

internal/profiles/keyring.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package profiles
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
"strings"
9+
)
10+
11+
// ResolveKeyringSecrets reads secrets from the host keyring and returns them
12+
// as environment variable entries (KEY=VALUE). Only runs on Linux where
13+
// secret-tool is available. Secrets are read before sandboxing so the
14+
// D-Bus session bus (required by secret-tool) is still accessible.
15+
//
16+
// If an env var is already set in the environment, the keyring lookup is
17+
// skipped for that variable (explicit env takes precedence).
18+
func ResolveKeyringSecrets(secrets map[string]KeyringLookup, debug bool) []string {
19+
if len(secrets) == 0 || runtime.GOOS != "linux" {
20+
return nil
21+
}
22+
23+
secretToolPath, err := exec.LookPath("secret-tool")
24+
if err != nil {
25+
if debug {
26+
fmt.Fprintf(os.Stderr, "[greywall:keyring] secret-tool not found, skipping keyring injection\n")
27+
}
28+
return nil
29+
}
30+
31+
var envVars []string
32+
for envName, lookup := range secrets {
33+
// Skip if already set in the environment
34+
if os.Getenv(envName) != "" {
35+
if debug {
36+
fmt.Fprintf(os.Stderr, "[greywall:keyring] %s already set, skipping keyring lookup\n", envName)
37+
}
38+
continue
39+
}
40+
41+
cmd := exec.Command(secretToolPath, "lookup", "service", lookup.Service) //nolint:gosec // args from trusted profile definitions
42+
out, err := cmd.Output()
43+
if err != nil {
44+
if debug {
45+
fmt.Fprintf(os.Stderr, "[greywall:keyring] Failed to read %s from keyring (service=%s): %v\n", envName, lookup.Service, err)
46+
}
47+
continue
48+
}
49+
50+
token := strings.TrimSpace(string(out))
51+
if token == "" {
52+
if debug {
53+
fmt.Fprintf(os.Stderr, "[greywall:keyring] Empty value for %s from keyring, skipping\n", envName)
54+
}
55+
continue
56+
}
57+
58+
envVars = append(envVars, envName+"="+token)
59+
if debug {
60+
fmt.Fprintf(os.Stderr, "[greywall:keyring] Injected %s from keyring (service=%s)\n", envName, lookup.Service)
61+
}
62+
}
63+
64+
return envVars
65+
}

internal/profiles/profiles_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,14 @@ func TestGetAgentProfile(t *testing.T) {
8383
}
8484

8585
if profiles.IsToolchain(name) {
86-
// Toolchain profiles should NOT have base paths
87-
for _, p := range profile.Filesystem.AllowRead {
88-
if p == "~/.gitconfig" {
89-
t.Errorf("toolchain %q should not have base path ~/.gitconfig", name)
86+
// Toolchain profiles should NOT have base paths inherited from BaseProfile.
87+
// However, toolchains like gh/glab may explicitly include paths they need
88+
// (e.g., ~/.gitconfig) in their own overlay, which is fine.
89+
if name != "gh" && name != "glab" {
90+
for _, p := range profile.Filesystem.AllowRead {
91+
if p == "~/.gitconfig" {
92+
t.Errorf("toolchain %q should not have base path ~/.gitconfig", name)
93+
}
9094
}
9195
}
9296
} else {

internal/profiles/registry.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import (
77
"github.com/GreyhavenHQ/greywall/internal/config"
88
)
99

10+
// KeyringLookup defines how to retrieve a secret from the system keyring.
11+
// On Linux, this uses secret-tool to read from gnome-keyring via libsecret.
12+
type KeyringLookup struct {
13+
// Service is the attribute value for "service" passed to secret-tool lookup.
14+
// Example: "gh:github.com" retrieves the GitHub CLI OAuth token.
15+
Service string
16+
}
17+
1018
// AgentDef is everything needed to define a known agent or toolchain profile.
1119
// Each file in the agents/ subpackage creates one of these and passes it to
1220
// Register() via an init() function, so adding a new entry is a single
@@ -24,6 +32,13 @@ type AgentDef struct {
2432
// Overlay returns the profile-specific config. For agents this is merged
2533
// on top of BaseProfile(); for toolchains it is used as-is.
2634
Overlay func() *config.Config
35+
36+
// KeyringSecrets maps environment variable names to keyring lookups.
37+
// On Linux, greywall reads these from the host keyring at startup (before
38+
// sandboxing) and injects them as environment variables. This avoids
39+
// exposing the D-Bus session bus (and gnome-keyring) inside the sandbox.
40+
// Ignored on macOS (keychain is accessible via file-based access).
41+
KeyringSecrets map[string]KeyringLookup
2742
}
2843

2944
var registry []AgentDef
@@ -84,6 +99,17 @@ func AvailableAgents() []string {
8499
return agents
85100
}
86101

102+
// GetKeyringSecrets returns all keyring secret mappings for the given canonical name.
103+
// Returns nil if the profile has no keyring secrets.
104+
func GetKeyringSecrets(canonical string) map[string]KeyringLookup {
105+
for _, def := range registry {
106+
if def.Names[0] == canonical && len(def.KeyringSecrets) > 0 {
107+
return def.KeyringSecrets
108+
}
109+
}
110+
return nil
111+
}
112+
87113
// AdHocCommands is the set of basic unix utilities that should not trigger
88114
// the first-run profile prompt. These are simple commands that don't need
89115
// their own config/cache directories. Toolchain commands (npm, uv, cargo,

internal/profiles/toolchains/scm.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func init() {
1313
return &config.Config{
1414
Filesystem: config.FilesystemConfig{
1515
AllowRead: []string{
16+
"~/.gitconfig", "~/.gitignore", "~/.config/git",
1617
"~/.config/gh", "~/.cache/gh", "~/.local/share/gh", "~/.local/state/gh",
1718
"~/.config/glab-cli", "~/.cache/glab-cli", "~/.local/share/glab-cli", "~/.local/state/glab-cli",
1819
},
@@ -23,5 +24,11 @@ func init() {
2324
},
2425
}
2526
},
27+
// On Linux, gh stores its OAuth token in gnome-keyring via libsecret.
28+
// The D-Bus session bus (and thus the keyring) is blocked inside the sandbox.
29+
// Read the token on the host via secret-tool and inject it as GH_TOKEN.
30+
KeyringSecrets: map[string]profiles.KeyringLookup{
31+
"GH_TOKEN": {Service: "gh:github.com"},
32+
},
2633
})
2734
}

internal/sandbox/linux_features.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,12 @@ func PrintDependencyStatus() []string {
330330
fmt.Println(CheckFail("xdg-dbus-proxy — optional, enables notify-send inside sandbox"))
331331
steps = append(steps, suggestInstallDbusProxy())
332332
}
333+
if commandExists("secret-tool") {
334+
fmt.Println(CheckOK("secret-tool (keyring credential injection for gh/glab)"))
335+
} else {
336+
fmt.Println(CheckFail("secret-tool — optional, injects keyring credentials (gh, glab) into sandbox"))
337+
steps = append(steps, suggestInstallSecretTool())
338+
}
333339

334340
// Network isolation (transparent proxy via tun2socks + network namespace)
335341
if features.CanUseTransparentProxy() {
@@ -411,6 +417,25 @@ func suggestInstallDbusProxy() string {
411417
}
412418
}
413419

420+
func suggestInstallSecretTool() string {
421+
switch {
422+
case commandExists("apt-get"):
423+
return "sudo apt install libsecret-tools"
424+
case commandExists("dnf"):
425+
return "sudo dnf install libsecret"
426+
case commandExists("yum"):
427+
return "sudo yum install libsecret"
428+
case commandExists("pacman"):
429+
return "sudo pacman -S libsecret"
430+
case commandExists("apk"):
431+
return "sudo apk add libsecret-tools"
432+
case commandExists("zypper"):
433+
return "sudo zypper install libsecret-tools"
434+
default:
435+
return "install libsecret-tools (provides secret-tool) using your package manager"
436+
}
437+
}
438+
414439
func readSysctl(name string) string {
415440
data, err := os.ReadFile("/proc/sys/" + name) //nolint:gosec // reading sysctl values - trusted kernel path
416441
if err != nil {

0 commit comments

Comments
 (0)