vault: add get --all to browse every field of an item
Some checks are pending
Build infra CLI / build (push) Waiting to run
ci/woodpecker/push/default Pipeline was successful

`homelab vault get` could only fetch one of five allow-listed fields and
had no way to see what fields an item even has — in particular it could
not reach arbitrary user-defined custom fields. Add a `--all` flag that
dumps the whole item as a normalized JSON object
(`{name, username?, password?, uris?, totp?, notes?, fields?}`), so a
Claude session can discover and read every field, custom ones included,
in a single call.

Security model preserved:
- Like `get --json`, the dump is all secret values, so it refuses a bare
  TTY (pipe it, e.g. `| jq`); the machine/agent path is stdout.
- The TOTP *seed* is reduced to a presence flag (`"totp": true`) and
  never emitted — the seed is more powerful than a one-time code, so the
  only seed-derived path stays the specially-audited `vault code`. Tests
  assert the seed and password-history never appear in the dump.
- Op-log uses a distinct `get-all` verb (item name still never logged) so
  a bulk dump is distinguishable from a single-field read.

`normalizeItem` is a pure, unit-tested core; `getItem` is the
session+fetch seam. CLI bumped to v0.10.0. Docs: README changelog,
onboarding runbook, design spec §16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-28 10:01:49 +00:00
parent b3c419e108
commit ccee443790
5 changed files with 332 additions and 4 deletions

View file

@ -217,6 +217,23 @@ name charset) run via the dbaas primary pod — the same exec path as `k8s db`.
| `edges --denied` | read | only `action='deny'` edges (blocked / lateral-movement) |
| `edges --json` / `--limit N` | read | JSON array output / row cap (default 200) |
### v0.10 — `vault get --all` (browse every field)
`vault get <name> --all` returns the **whole item** as a normalized JSON object,
so an agent can discover and read fields the single-field `--field` allowlist
can't reach — notably arbitrary **custom fields**.
| Command | Tier | What it does |
| --- | --- | --- |
| `vault get <name> --all` | read | all fields as JSON: `{name, username?, password?, uris?, totp?, notes?, fields?}` |
Shape notes: present standard fields only (empty ones omitted); `fields` is a
custom `name→value` map (duplicate names → last-wins; `linked` fields skipped).
The TOTP **seed is never emitted**`totp` is a presence flag (`true`), so the
only seed-derived path stays the specially-audited `vault code`. Like
`get --json`, the dump is all secret values, so it **refuses a terminal** — pipe
it (`homelab vault get <name> --all | jq`).
## Build / install
Built from source to `/usr/local/bin/homelab` during devvm provisioning

View file

@ -1 +1 @@
v0.9.0
v0.10.0

View file

@ -26,7 +26,7 @@ func vaultCommands() []Command {
{Path: []string{"vault", "list"}, Tier: TierRead,
Summary: "list your item names: vault list [--search Q]", Run: vaultList},
{Path: []string{"vault", "get"}, Tier: TierRead,
Summary: "fetch one item: vault get <name> [--field password|username|uri|notes|totp] [--json]", Run: vaultGet},
Summary: "fetch one item: vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet},
{Path: []string{"vault", "search"}, Tier: TierRead,
Summary: "search your item names: vault search <query>", Run: vaultSearch},
{Path: []string{"vault", "code"}, Tier: TierRead,
@ -48,6 +48,8 @@ func vaultHelp() string {
homelab vault list [--search Q] list your item names (no secrets)
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
TTY clipboard (auto-clears); piped stdout
homelab vault get <name> --all all fields (incl. custom) as JSON; piped only.
TOTP shown as presence flag use 'vault code' for a code.
homelab vault code <name> current TOTP code
homelab vault lock lock / log out the local bw session
@ -270,6 +272,7 @@ func bwSecretEnv(appdata string, c vwCreds, session string) []string {
func bwLoginArgs() []string { return []string{"login", "--apikey"} }
func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} }
func bwGetArgs(field, name string) []string { return []string{"get", field, name} }
func bwItemArgs(name string) []string { return []string{"get", "item", name} }
func bwStatusArgs() []string { return []string{"status"} }
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
@ -444,6 +447,7 @@ type getOpts struct {
name string
field string
json bool
all bool // dump every field (incl. custom) as normalized JSON
}
var validGetFields = map[string]bool{"password": true, "username": true, "uri": true, "notes": true, "totp": true}
@ -455,6 +459,8 @@ func parseGetArgs(args []string) (getOpts, error) {
switch {
case a == "--json":
o.json = true
case a == "--all":
o.all = true
case a == "--field" && i+1 < len(args):
o.field = args[i+1]
i++
@ -465,9 +471,10 @@ func parseGetArgs(args []string) (getOpts, error) {
}
}
if o.name == "" {
return o, fmt.Errorf("usage: homelab vault get <name> [--field password|username|uri|notes|totp] [--json]")
return o, fmt.Errorf("usage: homelab vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]")
}
if !validGetFields[o.field] {
// --all dumps the whole item, so --field is irrelevant — skip its allowlist.
if !o.all && !validGetFields[o.field] {
return o, fmt.Errorf("invalid --field %q (want password|username|uri|notes|totp)", o.field)
}
return o, nil
@ -483,6 +490,81 @@ func getValue(run cmdRunner, user, uid string, o getOpts) (string, error) {
return bwGet(run, s.env, o.field, o.name)
}
// getItem opens a session and returns the whole item as raw `bw get item` JSON.
// Used by `get --all`; normalization is a separate, pure step (normalizeItem).
func getItem(run cmdRunner, user, uid, name string) (string, error) {
s, err := openSession(run, user, uid)
if err != nil {
return "", err
}
return run("bw", bwItemArgs(name), s.env)
}
// normalizedItem is the browse-all-fields projection of a Vaultwarden item: the
// standard login fields that are present, notes, and a flat map of custom field
// name→value. bw internals (id, object, reprompt, passwordHistory) are dropped,
// and the TOTP *seed* is reduced to a presence flag — the only seed-derived path
// stays the specially-audited `vault code` (see the design §10/§16).
type normalizedItem struct {
Name string `json:"name"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
URIs []string `json:"uris,omitempty"`
TOTP bool `json:"totp,omitempty"` // presence only, never the seed
Notes string `json:"notes,omitempty"`
Fields map[string]string `json:"fields,omitempty"` // custom field name→value
}
// bwFieldLinked is the Bitwarden custom-field type for a "linked" field: it
// references another field and carries a null value, so it is not real data.
const bwFieldLinked = 3
// normalizeItem parses a `bw get item` payload into the browse projection. It is
// pure (no I/O), so it is the unit-tested heart of `get --all`.
func normalizeItem(raw string) (normalizedItem, error) {
var it struct {
Name string `json:"name"`
Notes string `json:"notes"`
Login *struct {
Username string `json:"username"`
Password string `json:"password"`
Totp string `json:"totp"`
URIs []struct {
URI string `json:"uri"`
} `json:"uris"`
} `json:"login"`
Fields []struct {
Name string `json:"name"`
Value string `json:"value"`
Type int `json:"type"`
} `json:"fields"`
}
if err := json.Unmarshal([]byte(raw), &it); err != nil {
return normalizedItem{}, fmt.Errorf("parse bw item: %w", err)
}
n := normalizedItem{Name: it.Name, Notes: it.Notes}
if it.Login != nil {
n.Username = it.Login.Username
n.Password = it.Login.Password
n.TOTP = it.Login.Totp != ""
for _, u := range it.Login.URIs {
if u.URI != "" {
n.URIs = append(n.URIs, u.URI)
}
}
}
for _, f := range it.Fields {
if f.Type == bwFieldLinked {
continue // references another field, no value of its own
}
if n.Fields == nil {
n.Fields = map[string]string{}
}
n.Fields[f.Name] = f.Value // duplicate names: last-wins (rare; documented)
}
return n, nil
}
// clipboardDecision picks how to return a secret value. "stdout" prints it (a
// pipe/agent — the intended machine path); "clipboard" copies via OSC52;
// "refuse" emits nothing sensitive (would otherwise risk dumping the secret's
@ -789,6 +871,9 @@ func vaultGet(args []string) error {
}
defer unlock()
user := vaultCurrentUser()
if o.all {
return getAllFields(user, uid, o.name)
}
val, err := getValue(realRunner, user, uid, o)
if err != nil {
return err
@ -804,3 +889,29 @@ func vaultGet(args []string) error {
emitSecret(val)
return nil
}
// getAllFields prints every field of one item as normalized JSON. Like
// `get --json`, the payload is all secret values, so it refuses a terminal
// (pipe it). The TOTP seed is never emitted — only a presence flag — so no extra
// TOTP audit is needed; the op-log uses a distinct verb so a bulk dump is
// distinguishable from a single-field get (the item name is still never logged).
func getAllFields(user, uid, name string) error {
if !jsonToStdoutOK(stdoutIsTTY()) {
return fmt.Errorf("refusing to print all fields as JSON to a terminal; pipe it (e.g. | jq)")
}
raw, err := getItem(realRunner, user, uid, name)
if err != nil {
return err
}
item, err := normalizeItem(raw)
if err != nil {
return err
}
out, err := json.Marshal(item)
if err != nil {
return err
}
writeOpLog(opRecord{User: user, Verb: "get-all", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: name})
fmt.Println(string(out))
return nil
}

View file

@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
@ -620,3 +621,201 @@ func TestGetValueFlow(t *testing.T) {
t.Fatalf("getValue = %q, %v", val, err)
}
}
// --- vault get --all (browse all fields) ----------------------------------
func TestParseGetArgsAll(t *testing.T) {
o, err := parseGetArgs([]string{"github", "--all"})
if err != nil || o.name != "github" || !o.all {
t.Fatalf("parseGetArgs(--all) = %+v err=%v", o, err)
}
// --all must skip --field validation (field is irrelevant for a full dump).
if _, err := parseGetArgs([]string{"github", "--all", "--field", "evil"}); err != nil {
t.Fatalf("--all must ignore an otherwise-invalid --field, got err=%v", err)
}
// A name is still required.
if _, err := parseGetArgs([]string{"--all"}); err == nil {
t.Fatal("get --all with no name must error")
}
// Without --all, the field allowlist still applies.
if _, err := parseGetArgs([]string{"github", "--field", "evil"}); err == nil {
t.Fatal("invalid --field without --all must still error")
}
}
func TestBwItemArgs(t *testing.T) {
argv := bwItemArgs("github")
if !reflect.DeepEqual(argv, []string{"get", "item", "github"}) {
t.Fatalf("bwItemArgs = %v", argv)
}
for _, a := range argv {
if strings.Contains(a, "SESSION") || a == "--session" {
t.Fatalf("session must travel via env, not argv: %v", argv)
}
}
}
// a representative `bw get item` payload: login fields, multiple URIs, a TOTP
// seed, notes, custom fields (text/hidden/boolean), plus bw internals that MUST
// be dropped (id/object/reprompt/passwordHistory).
const sampleLoginItemJSON = `{
"object":"item","id":"abc-123","folderId":null,"type":1,"reprompt":0,
"name":"GitHub","notes":"my notes","favorite":false,
"fields":[
{"name":"PIN","value":"1234","type":1},
{"name":"endpoint","value":"https://api.gh","type":0},
{"name":"enabled","value":"true","type":2}
],
"login":{
"username":"octocat","password":"hunter2",
"totp":"otpauth://totp/GitHub:octocat?secret=SEEDSEEDSEED",
"uris":[{"match":null,"uri":"https://github.com"},{"match":null,"uri":"https://gist.github.com"}]
},
"passwordHistory":[{"password":"OLD-PASSWORD-XYZ"}]
}`
func TestNormalizeItemLogin(t *testing.T) {
n, err := normalizeItem(sampleLoginItemJSON)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
if n.Name != "GitHub" || n.Username != "octocat" || n.Password != "hunter2" || n.Notes != "my notes" {
t.Fatalf("standard fields wrong: %+v", n)
}
if !n.TOTP {
t.Fatal("TOTP presence flag must be true when a seed exists")
}
if !reflect.DeepEqual(n.URIs, []string{"https://github.com", "https://gist.github.com"}) {
t.Fatalf("URIs = %v", n.URIs)
}
want := map[string]string{"PIN": "1234", "endpoint": "https://api.gh", "enabled": "true"}
if !reflect.DeepEqual(n.Fields, want) {
t.Fatalf("custom fields = %v want %v", n.Fields, want)
}
}
// The load-bearing security test: the raw TOTP seed (more powerful than a
// one-time code) and the password history must NEVER appear in the dump.
func TestNormalizeItemNeverLeaksSeedOrHistory(t *testing.T) {
n, err := normalizeItem(sampleLoginItemJSON)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
out, err := json.Marshal(n)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, leak := range []string{"SEEDSEEDSEED", "otpauth", "OLD-PASSWORD-XYZ", "passwordHistory", "abc-123"} {
if strings.Contains(string(out), leak) {
t.Fatalf("dump leaked %q: %s", leak, out)
}
}
}
func TestNormalizeItemNoTOTP(t *testing.T) {
n, err := normalizeItem(`{"name":"X","type":1,"login":{"username":"u","password":"p"}}`)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
if n.TOTP {
t.Fatal("TOTP must be false when no seed present")
}
out, _ := json.Marshal(n)
if strings.Contains(string(out), "totp") {
t.Fatalf("no-totp item must omit the totp key entirely: %s", out)
}
}
func TestNormalizeItemEmptyStandardFieldsOmitted(t *testing.T) {
n, err := normalizeItem(`{"name":"Bare","type":1,"login":{"username":"","password":"","totp":"","uris":[]},"fields":[{"name":"only","value":"x","type":0}]}`)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
out, _ := json.Marshal(n)
for _, k := range []string{"username", "password", "uris", "notes", "totp"} {
if strings.Contains(string(out), `"`+k+`"`) {
t.Fatalf("empty standard field %q must be omitted: %s", k, out)
}
}
if !strings.Contains(string(out), `"name":"Bare"`) || !strings.Contains(string(out), `"only":"x"`) {
t.Fatalf("name + custom field must survive: %s", out)
}
}
func TestNormalizeItemSecureNoteNullLogin(t *testing.T) {
// type 2 (secure note): login is null — must not panic; notes + custom fields survive.
n, err := normalizeItem(`{"name":"SN","type":2,"notes":"secret note","login":null,"fields":[{"name":"k","value":"v","type":1}]}`)
if err != nil {
t.Fatalf("normalizeItem(null login): %v", err)
}
if n.Name != "SN" || n.Notes != "secret note" || n.Fields["k"] != "v" {
t.Fatalf("secure-note normalize wrong: %+v", n)
}
if n.Username != "" || n.Password != "" || n.TOTP {
t.Fatalf("login fields must be empty for a login-less item: %+v", n)
}
}
func TestNormalizeItemDuplicateCustomNames(t *testing.T) {
// Bitwarden permits duplicate custom-field names; a JSON object can't hold
// dups, so last-wins (documented).
n, err := normalizeItem(`{"name":"D","fields":[{"name":"k","value":"first","type":0},{"name":"k","value":"second","type":0}]}`)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
if n.Fields["k"] != "second" {
t.Fatalf("duplicate custom names must be last-wins, got %q", n.Fields["k"])
}
}
func TestNormalizeItemLinkedFieldSkipped(t *testing.T) {
// type 3 (linked) fields reference another field and carry a null value —
// they are not real data and must be skipped.
n, err := normalizeItem(`{"name":"L","login":{"username":"u"},"fields":[{"name":"linked","value":null,"type":3},{"name":"real","value":"r","type":0}]}`)
if err != nil {
t.Fatalf("normalizeItem: %v", err)
}
if _, ok := n.Fields["linked"]; ok {
t.Fatalf("linked field must be skipped: %v", n.Fields)
}
if n.Fields["real"] != "r" {
t.Fatalf("real custom field dropped: %v", n.Fields)
}
}
func TestNormalizeItemMalformed(t *testing.T) {
if _, err := normalizeItem("not json"); err == nil {
t.Fatal("malformed item JSON must error")
}
}
// getItem opens a session and runs `bw get item <name>`, returning raw JSON.
func TestGetItemFlow(t *testing.T) {
f := &fakeRunner{out: map[string]string{
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw",
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
"bw status": `{"status":"locked"}`,
"bw unlock": "SESS",
"bw get item github": sampleLoginItemJSON,
}}
uid := fmt.Sprintf("%d", os.Getuid())
raw, err := getItem(f.run, "emo", uid, "github")
if err != nil || !strings.Contains(raw, `"name":"GitHub"`) {
t.Fatalf("getItem = %q, %v", raw, err)
}
// The session key must reach bw via env, never argv.
for _, call := range f.calls {
for _, arg := range call {
if strings.Contains(arg, "SESS") {
t.Errorf("session leaked into argv: %v", call)
}
}
}
}
func TestVaultHelpMentionsAll(t *testing.T) {
if !strings.Contains(vaultHelp(), "--all") {
t.Error("vault help must document --all")
}
}

View file

@ -14,6 +14,7 @@ homelab vault setup one-time: store VW email + master password + API
homelab vault status configured / unlocked / reachable (no secrets)
homelab vault list [--search Q] item names (no secrets)
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
homelab vault get <name> --all all fields (incl. custom) as JSON; pipe it (| jq)
homelab vault code <name> current TOTP code
homelab vault lock lock / log out the local bw session
```