Skip to content
Open
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
84 changes: 84 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ providers:
| `dotenv` | Stable |
| `gcloud_secretmanager` | Stable |
| `infisical` | Stable |
| `keyring` | Stable |
| `template` | Stable |
| `vault` | Stable |

Expand Down Expand Up @@ -375,6 +376,71 @@ The provider uses the Infisical Go SDK to authenticate with Infisical using Univ
- When `include_imports: true`, secrets imported from other projects are included
- When `expand_secrets: true`, secret references (e.g., `${OTHER_SECRET}`) are expanded to their actual values

### Keyring (`keyring`)

Reads a secret from the operating system's credential store: Keychain on macOS, Credential Manager on Windows, and Secret Service on Linux.

**Dependencies:**
- No CLI required. On Linux a Secret Service implementation must be running (for example `gnome-keyring`), which is often absent on headless hosts.

**Configuration:**
- `service` (required): The item's service name
- `user` (required): The item's account name
- `pointer` (optional): An [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON pointer selecting a node inside a JSON payload, for example `/claudeAiOauth/accessToken`
- `key` (optional): The environment variable name to use when the payload is not a JSON object. Defaults to `<PROVIDER_ID>_SECRET`

Both `service` and `user` are required because the credential store cannot be enumerated — an item is only reachable through its exact identity.

**Example:**
```yaml
providers:
- kind: keyring
id: db
service: myapp
user: postgres
```

**Example with a JSON pointer:**
```yaml
providers:
- kind: keyring
id: claude
service: Claude Code-credentials
user: husni
pointer: /claudeAiOauth/accessToken
key: CLAUDE_TOKEN
```

**JSON Secrets:**
If the item's value is a JSON object, it is parsed and each key-value pair is mapped according to the `keys` configuration, the same as the cloud providers. If `keys` is empty, all keys are mapped. See [Value Types](#value-types) for how non-string JSON values become environment variables.

**Plain Text Secrets:**
If the value is not a JSON object, it is mapped to a single environment variable named `<PROVIDER_ID>_SECRET` (the provider's `id` uppercased, with hyphens converted to underscores). Set `key` to choose a different name.

**Narrowing with `pointer`:**
Without `pointer`, the whole item is read. A credential blob often holds many unrelated secrets, and every one of them would then be exported to the child process. `pointer` selects a single node first: if it resolves to an object the object is expanded, otherwise it becomes one variable named by `key`.

`pointer` requires the item's value to be JSON. Pointing at a value that is not JSON, or at a node that does not exist, is an error rather than an empty result.

The field is called `pointer` rather than `path` because in `dotenv`, `infisical` and `vault`, `path` already means where the secret lives, not where to look inside it.

**Populating the store:**

This provider only reads. Create items with the tools your platform provides:

```bash
# macOS
security add-generic-password -s myapp -a postgres -w

# Windows (PowerShell or cmd)
cmdkey /generic:myapp /user:postgres /pass

# Linux
secret-tool store --label=myapp service myapp username postgres
```

**Note:** sstart can never read an item that the OS does not release to it. On macOS, an item created by another application carries an access control list, and the system prompts before releasing it to a different binary.

### HashiCorp Vault / OpenBao (`vault`)

Retrieves secrets from HashiCorp Vault or OpenBao. Supports both KV v1 and KV v2 secret engines. OpenBao is a community-driven fork of HashiCorp Vault that maintains API compatibility, so the same `vault` provider works with both systems.
Expand Down Expand Up @@ -737,6 +803,24 @@ keys:
- Use `==` to keep the source key name as the target name
- Keys are case-sensitive

### Value Types

Environment variables are always strings, so values from a JSON secret are
converted:

| JSON value | Environment variable |
|---|---|
| `"token"` | `token` |
| `1754110382` | `1754110382` |
| `1.5` | `1.5` |
| `true` | `true` |
| `["read","write"]` | `["read","write"]` |
| `{"token":"secret"}` | `{"token":"secret"}` |
| `null` | (empty string) |

Numbers keep the digits they were written with. Arrays and objects are passed
through as JSON, so the receiving process can parse them back.

## Environment Inheritance

By default, sstart inherits all system environment variables and adds secrets on top. To create a clean environment with only secrets (no system environment variables), set `inherit: false`:
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
_ "github.com/dirathea/sstart/internal/provider/dotenv"
_ "github.com/dirathea/sstart/internal/provider/gcsm"
_ "github.com/dirathea/sstart/internal/provider/infisical"
_ "github.com/dirathea/sstart/internal/provider/keyring"
_ "github.com/dirathea/sstart/internal/provider/onepassword"
_ "github.com/dirathea/sstart/internal/provider/template"
_ "github.com/dirathea/sstart/internal/provider/vault"
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/aws/secretsmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func (p *SecretsManagerProvider) Fetch(secretContext provider.SecretContext, map
}

// Parse the secret value (assuming JSON format)
var secretData map[string]interface{}
if err := json.Unmarshal([]byte(*result.SecretString), &secretData); err != nil {
secretData, err := provider.DecodeSecretJSON([]byte(*result.SecretString))
if err != nil {
// If not JSON, treat as a single value
secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET"
log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey)
Expand Down Expand Up @@ -118,7 +118,7 @@ func (p *SecretsManagerProvider) Fetch(secretContext provider.SecretContext, map
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/azurekeyvault/azurekeyvault.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func (p *AzureKeyVaultProvider) Fetch(secretContext provider.SecretContext, mapI
}

// Try to parse as JSON first
var secretData map[string]interface{}
if err := json.Unmarshal([]byte(secretValue), &secretData); err != nil {
secretData, err := provider.DecodeSecretJSON([]byte(secretValue))
if err != nil {
// If not JSON, treat as a single value
secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET"
log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey)
Expand Down Expand Up @@ -118,7 +118,7 @@ func (p *AzureKeyVaultProvider) Fetch(secretContext provider.SecretContext, mapI
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
6 changes: 4 additions & 2 deletions internal/provider/bitwarden/bitwarden.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,11 @@ func (p *BitwardenProvider) Fetch(secretContext provider.SecretContext, mapID st
if item.Notes == "" {
return nil, fmt.Errorf("bitwarden item '%s' has no notes for 'note' format", cfg.ItemID)
}
if err := json.Unmarshal([]byte(item.Notes), &secretData); err != nil {
parsedNotes, err := provider.DecodeSecretJSON([]byte(item.Notes))
if err != nil {
return nil, fmt.Errorf("failed to parse notes as JSON for bitwarden item '%s': %w", cfg.ItemID, err)
}
secretData = parsedNotes
case "login":
// Extract login credentials
if item.Login == nil {
Expand Down Expand Up @@ -301,7 +303,7 @@ func (p *BitwardenProvider) Fetch(secretContext provider.SecretContext, mapID st
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/bitwarden/bitwarden_sm.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (p *BitwardenSMProvider) Fetch(secretContext provider.SecretContext, mapID
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/gcsm/gcsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ func (p *GCSMProvider) Fetch(secretContext provider.SecretContext, mapID string,
}

// Parse the secret value (assuming JSON format)
secretData := make(map[string]interface{})
secretString := string(result.Payload.Data)
if err := json.Unmarshal([]byte(secretString), &secretData); err != nil {
secretData, err := provider.DecodeSecretJSON([]byte(secretString))
if err != nil {
// If not JSON, treat as a single value
secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET"
log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey)
Expand Down Expand Up @@ -115,7 +115,7 @@ func (p *GCSMProvider) Fetch(secretContext provider.SecretContext, mapID string,
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/infisical/infisical.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (p *InfisicalProvider) Fetch(secretContext provider.SecretContext, mapID st
continue
}

value := fmt.Sprintf("%v", v)
value := provider.StringifyValue(v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Value: value,
Expand Down
55 changes: 55 additions & 0 deletions internal/provider/jsonpointer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package provider

import (
"fmt"
"strconv"
"strings"
)

// ResolvePointer resolves an RFC 6901 JSON pointer against a decoded JSON
// document.
//
// A pointer is used rather than a dotted path because real key names contain
// dots, pipes and colons; RFC 6901 already defines how to escape the two
// characters that are special to it.
func ResolvePointer(document interface{}, pointer string) (interface{}, error) {
if pointer == "" {
return document, nil
}

if !strings.HasPrefix(pointer, "/") {
return nil, fmt.Errorf("JSON pointer %q must start with '/'", pointer)
}

current := document
// A leading "/" produces an empty first element, which is not a token.
for _, token := range strings.Split(pointer, "/")[1:] {
// ~1 must be decoded before ~0, otherwise "~01" would become "/".
token = strings.ReplaceAll(token, "~1", "/")
token = strings.ReplaceAll(token, "~0", "~")

switch container := current.(type) {
case map[string]interface{}:
value, exists := container[token]
if !exists {
return nil, fmt.Errorf("JSON pointer %q: no key %q", pointer, token)
}
current = value

case []interface{}:
index, err := strconv.Atoi(token)
if err != nil {
return nil, fmt.Errorf("JSON pointer %q: %q is not an array index", pointer, token)
}
if index < 0 || index >= len(container) {
return nil, fmt.Errorf("JSON pointer %q: index %d is out of range, length is %d", pointer, index, len(container))
}
current = container[index]

default:
return nil, fmt.Errorf("JSON pointer %q: cannot descend into %T at %q", pointer, current, token)
}
}

return current, nil
}
119 changes: 119 additions & 0 deletions internal/provider/jsonpointer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package provider

import (
"encoding/json"
"testing"
)

func TestResolvePointer(t *testing.T) {
document, err := DecodeSecretJSONValue([]byte(`{
"claudeAiOauth": {"accessToken": "sk-secret", "expiresAt": 1754110382, "scopes": ["read", "write"]},
"mcpOAuth": {"plugin:engineering:github|1eea5f27": {"accessToken": "gh-token"}},
"a/b": "slash",
"m~n": "tilde",
"": "empty key"
}`))
if err != nil {
t.Fatalf("DecodeSecretJSONValue() error = %v", err)
}

tests := []struct {
name string
pointer string
want string
}{
{"nested scalar", "/claudeAiOauth/accessToken", "sk-secret"},
{"number keeps digits", "/claudeAiOauth/expiresAt", "1754110382"},
{"array index", "/claudeAiOauth/scopes/0", "read"},
{"array index last", "/claudeAiOauth/scopes/1", "write"},
{"key with pipe and colon", "/mcpOAuth/plugin:engineering:github|1eea5f27/accessToken", "gh-token"},
{"escaped slash", "/a~1b", "slash"},
{"escaped tilde", "/m~0n", "tilde"},
{"empty key", "/", "empty key"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolvePointer(document, tt.pointer)
if err != nil {
t.Fatalf("ResolvePointer(%q) error = %v", tt.pointer, err)
}
if StringifyValue(got) != tt.want {
t.Errorf("ResolvePointer(%q) = %q, want %q", tt.pointer, StringifyValue(got), tt.want)
}
})
}
}

func TestResolvePointer_WholeDocument(t *testing.T) {
document, err := DecodeSecretJSONValue([]byte(`{"a":"b"}`))
if err != nil {
t.Fatalf("DecodeSecretJSONValue() error = %v", err)
}

got, err := ResolvePointer(document, "")
if err != nil {
t.Fatalf("ResolvePointer(\"\") error = %v", err)
}
if StringifyValue(got) != `{"a":"b"}` {
t.Errorf("ResolvePointer(\"\") = %q, want the whole document", StringifyValue(got))
}
}

func TestResolvePointer_Container(t *testing.T) {
document, err := DecodeSecretJSONValue([]byte(`{"outer":{"inner":"v"}}`))
if err != nil {
t.Fatalf("DecodeSecretJSONValue() error = %v", err)
}

got, err := ResolvePointer(document, "/outer")
if err != nil {
t.Fatalf("ResolvePointer() error = %v", err)
}
if _, ok := got.(map[string]interface{}); !ok {
t.Errorf("ResolvePointer(\"/outer\") returned %T, want a map", got)
}
}

func TestResolvePointer_Errors(t *testing.T) {
document, err := DecodeSecretJSONValue([]byte(`{"a":{"b":"c"},"list":["x"],"scalar":"s"}`))
if err != nil {
t.Fatalf("DecodeSecretJSONValue() error = %v", err)
}

tests := []struct {
name string
pointer string
}{
{"missing leading slash", "a/b"},
{"unknown key", "/nope"},
{"unknown nested key", "/a/nope"},
{"index out of range", "/list/5"},
{"non numeric index", "/list/x"},
{"negative index", "/list/-1"},
{"descend into scalar", "/scalar/deeper"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := ResolvePointer(document, tt.pointer); err == nil {
t.Errorf("ResolvePointer(%q) succeeded, want an error", tt.pointer)
}
})
}
}

func TestResolvePointer_NumberIndexIsNotAFloat(t *testing.T) {
document, err := DecodeSecretJSONValue([]byte(`{"list":[1754110382]}`))
if err != nil {
t.Fatalf("DecodeSecretJSONValue() error = %v", err)
}

got, err := ResolvePointer(document, "/list/0")
if err != nil {
t.Fatalf("ResolvePointer() error = %v", err)
}
if _, ok := got.(json.Number); !ok {
t.Fatalf("resolved as %T, want json.Number", got)
}
}
Loading