From 2da98c3dd4304f072ba0758a9978c84c79f1670f Mon Sep 17 00:00:00 2001 From: sscodeai Date: Wed, 21 Oct 2026 21:20:43 +0900 Subject: [PATCH 1/6] fix(mask): mask by key name, not only by value shape KeyNameIsSensitive existed but nothing called it, so a sensitive key holding a passphrase without digits (DB_PASSWORD=correcthorsebatterystaple) was returned verbatim by get/list, the MCP get tool and the MCP resource. MaskKey applies the key-name rule first and falls back to the value heuristics. store.List, the MCP get handler, the CLI get/set/vault commands, the Vault KV listing and the leak-scan report now use it. --- cmd/keysmith/main.go | 10 +++++----- internal/leakscan/leakscan.go | 2 +- internal/mask/mask.go | 20 ++++++++++++++++++++ internal/mask/mask_test.go | 23 +++++++++++++++++++++++ internal/mcp/server.go | 2 +- internal/store/store.go | 2 +- internal/store/store_test.go | 14 ++++++++++++++ internal/vault/client.go | 16 +++------------- 8 files changed, 68 insertions(+), 21 deletions(-) diff --git a/cmd/keysmith/main.go b/cmd/keysmith/main.go index d320cd1..eb70f72 100644 --- a/cmd/keysmith/main.go +++ b/cmd/keysmith/main.go @@ -157,7 +157,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if unsafe { fmt.Println(val) } else { - fmt.Println(mask.Mask(val)) + fmt.Println(mask.MaskKey(key, val)) } return nil @@ -179,7 +179,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if err := st.Set(key, value); err != nil { return err } - fmt.Printf("stored %s (masked: %s)\n", key, mask.Mask(value)) + fmt.Printf("stored %s (masked: %s)\n", key, mask.MaskKey(key, value)) return nil case "rotate": @@ -246,7 +246,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if containsFlag(rest, "--unsafe") { fmt.Println(val) } else { - fmt.Println(mask.Mask(val)) + fmt.Println(mask.MaskKey(rest[0], val)) } return nil @@ -271,7 +271,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if err := vc.SetKV(context.Background(), rest[0], value); err != nil { return err } - fmt.Printf("stored %s in vault (masked: %s)\n", rest[0], mask.Mask(value)) + fmt.Printf("stored %s in vault (masked: %s)\n", rest[0], mask.MaskKey(rest[0], value)) return nil case "vault-kv-list": @@ -302,7 +302,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { return err } fmt.Printf("username: %s\n", creds.Username) - fmt.Printf("password: %s (masked)\n", mask.Mask(creds.Password)) + fmt.Printf("password: %s (masked)\n", mask.MaskKey("DB_PASSWORD", creds.Password)) fmt.Printf("lease_id: %s\n", creds.LeaseID) fmt.Printf("ttl: %ds (auto-expires — leaked cred is harmless)\n", creds.LeaseTTL) fmt.Printf("renewable: %v\n", creds.Renewable) diff --git a/internal/leakscan/leakscan.go b/internal/leakscan/leakscan.go index ee43eaa..d1a3145 100644 --- a/internal/leakscan/leakscan.go +++ b/internal/leakscan/leakscan.go @@ -80,7 +80,7 @@ func ScanGitHistory(repoDir string, st *store.Store, autoRotate bool) ([]ScanRes if autoRotate && !rotated { if _, err := st.Rotate(key, 32); err == nil { res.Rotated = true - res.NewMasked = mask.Mask(mustGet(st, key)) + res.NewMasked = mask.MaskKey(key, mustGet(st, key)) rotated = true } } diff --git a/internal/mask/mask.go b/internal/mask/mask.go index ce34d93..ba07cde 100644 --- a/internal/mask/mask.go +++ b/internal/mask/mask.go @@ -125,6 +125,26 @@ func Mask(value string) string { return maskPlain(value) } +// MaskKey masks a stored value with the key name taken into account. +// +// A key name that marks a credential (DB_PASSWORD, GITHUB_TOKEN, ...) forces +// masking even when the value itself does not match a value heuristic: a +// passphrase without digits, a DSN, or a short password is still a secret, and +// value-only heuristics cannot recognise every one. The key name is the one +// signal the caller always has, so it is applied first. +func MaskKey(key, value string) string { + if value == "" { + return "" + } + if !KeyNameIsSensitive(key) { + return Mask(value) + } + if urlSchemeRe.MatchString(value) { + return MaskURL(value) + } + return maskPlain(value) +} + // MaskURL masks a URL-shaped value per segment: userinfo password always // masked, random-looking runs in path/query masked, scheme/user/host/port stay // clear. diff --git a/internal/mask/mask_test.go b/internal/mask/mask_test.go index 4d97449..145825d 100644 --- a/internal/mask/mask_test.go +++ b/internal/mask/mask_test.go @@ -69,6 +69,29 @@ func TestMask(t *testing.T) { } } +func TestMaskKeyUsesTheKeyName(t *testing.T) { + cases := []struct { + key, val, want string + }{ + // A sensitive key name forces masking even though the value has no + // digit and no credential prefix. + {"DB_PASSWORD", "correcthorsebatterystaple", "co******le"}, + {"DB_PASSWORD", "hunter2", "hu******r2"}, + {"GITHUB_TOKEN", "short", "sh******rt"}, + // URL values keep their scheme/host but lose the password. + {"DB_PASSWORD", "postgres://app:s3cret@db.internal/app", "postgres://app:s3******et@db.internal/app"}, + // A non-sensitive key keeps plain configuration readable. + {"DEBUG", "correcthorsebatterystaple", "correcthorsebatterystaple"}, + // A URL value is still segment-masked by the value heuristics. + {"DATABASE_URL", "postgres://app:s3cret@db.internal/app", "postgres://app:s3******et@db.internal/app"}, + } + for _, c := range cases { + if got := MaskKey(c.key, c.val); got != c.want { + t.Errorf("MaskKey(%q, %q) = %q, want %q", c.key, c.val, got, c.want) + } + } +} + func TestMaskURL(t *testing.T) { cases := []struct { val string diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 4d1fb3f..0213d87 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -117,7 +117,7 @@ func NewServer(st *store.Store, version string) (*Server, error) { } return nil, nil, fmt.Errorf("get %q: %w", args.Key, err) } - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: mask.Mask(v)}}}, nil, nil + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: mask.MaskKey(args.Key, v)}}}, nil, nil }) // put: set a secret. Value is read from a temp file path arg, so the diff --git a/internal/store/store.go b/internal/store/store.go index 4b0a8c1..60dfb9c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -219,7 +219,7 @@ func (s *Store) List() (map[string]string, error) { } out := make(map[string]string, len(m)) for k, v := range m { - out[k] = mask.Mask(v) + out[k] = mask.MaskKey(k, v) } return out, nil } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index e7933d6..85c1c15 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -86,6 +86,20 @@ func TestListIsMasked(t *testing.T) { } } +func TestListMasksSensitiveKeyNames(t *testing.T) { + s := newTestStore(t) + if err := s.Set("DB_PASSWORD", "correcthorsebatterystaple"); err != nil { + t.Fatalf("Set: %v", err) + } + items, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if v := items["DB_PASSWORD"]; v == "correcthorsebatterystaple" { + t.Errorf("List leaked a passphrase for a sensitive key name: %q", v) + } +} + func TestDelete(t *testing.T) { s := newTestStore(t) if err := s.Set("TMP", "secret1"); err != nil { diff --git a/internal/vault/client.go b/internal/vault/client.go index 389e704..903b998 100644 --- a/internal/vault/client.go +++ b/internal/vault/client.go @@ -17,6 +17,8 @@ import ( "net/http" "strings" "time" + + "github.com/sscodeai/keysmith/internal/mask" ) // Client is a minimal Vault HTTP API client. It deliberately implements only @@ -116,7 +118,7 @@ func (c *Client) ListKV(ctx context.Context) (map[string]string, error) { if err != nil { continue } - result[k] = maskValue(v) + result[k] = mask.MaskKey(k, v) } return result, nil } @@ -194,15 +196,3 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body []byte, o } return nil } - -// maskValue is a tiny inline masker for Vault values (reuses mask pkg when -// wired, but kept standalone to avoid import cycle). -func maskValue(v string) string { - if v == "" { - return "" - } - if len(v) <= 4 { - return v[:1] + "***" - } - return v[:2] + "******" + v[len(v)-2:] -} From 8dc8d0f1d37e517257cd18922a86cefcce46e84d Mon Sep 17 00:00:00 2001 From: sscodeai Date: Wed, 21 Oct 2026 21:22:59 +0900 Subject: [PATCH 2/6] fix(mcp): confine put value_file to the store inbox put read any path the agent supplied and unlinked it afterwards, which made the tool an arbitrary-file read (store a file, then read it back with get when the value does not look secret) and an arbitrary-file delete. Value files now have to resolve inside /inbox: symlink escapes, relative escapes and absolute paths elsewhere are refused. The inbox is created 0700, `keysmith inbox` prints it, and the tool description tells the agent where to write. e2e_test.py asserts the refusal and that the outside file survives. --- AGENTS.ja.md | 4 +- AGENTS.md | 8 +++- README.ja.md | 9 +++-- README.md | 14 +++++-- cmd/keysmith/main.go | 10 +++++ e2e_test.py | 29 +++++++++++--- internal/mcp/server.go | 76 +++++++++++++++++++++++++++-------- internal/mcp/server_test.go | 80 +++++++++++++++++++++++++++++++++++++ internal/store/store.go | 4 ++ skill/SKILL.ja.md | 2 +- skill/SKILL.md | 7 ++-- 11 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 internal/mcp/server_test.go diff --git a/AGENTS.ja.md b/AGENTS.ja.md index d41a5c3..bc606b2 100644 --- a/AGENTS.ja.md +++ b/AGENTS.ja.md @@ -15,8 +15,8 @@ 1. `secrets.enc` は平文の key material を決して含んではいけません。age で暗号化します。 2. `key.txt` は `0600`、store dir は `0700` でなければなりません。 -3. すべての MCP tool result と resource はマスク済みでなければなりません。tool result に平文を出してはいけません。 -4. `put` は `value_file` パスを受け取り、それを読み、読み取り後に削除しなければなりません。 +3. すべての MCP tool result と resource はマスク済みでなければなりません。tool result に平文を出してはいけません。`mask.MaskKey` を使い、機密キー名 (DB_PASSWORD, GITHUB_TOKEN...) の場合は値がヒューリスティックに一致しなくてもマスクしてください。 +4. `put` は **`/inbox` 内** の `value_file` パスを受け取り、それを読み、読み取り後に削除しなければなりません。inbox の外のパス (外を指す symlink を含む) は拒否します。引数はエージェントが制御するためです。 5. マスキング規則は `internal/mask` にあります。entropy/prefix/URL segmentation のテストを green に保ってください。 6. **redemption は fail-closed です** (`internal/redeem`、docs/THREAT-MODEL.md)。未知のセッション、期限切れセッション、そのセッションで発行されていないキー名、値の欠落または空値、不正な予約プレフィックス、`argv` 内のトークン、audit log の書き込み失敗。いずれの場合もコマンドを起動しません。トークンをそのまま転送するフォールバックも、平文を転送するフォールバックも存在せず、エラーメッセージに値を含めてはいけません。 7. redemption は `argv` に値を置かず、値も引数リスト全体もログに残しません (`/audit.log` はキー名のみを記録します)。 diff --git a/AGENTS.md b/AGENTS.md index 9e85a45..76f0b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,12 @@ agents that develop it. 1. `secrets.enc` must never contain plaintext key material (age-encrypted). 2. `key.txt` must be `0600`, store dir `0700`. 3. Every MCP tool result and resource must be masked — no plaintext in - tool results, ever. -4. `put` must accept a `value_file` path, read it, and delete it after. + tool results, ever. Use `mask.MaskKey`, so a sensitive key name + (DB_PASSWORD, GITHUB_TOKEN, ...) masks the value even when the value itself + does not match a heuristic. +4. `put` must accept a `value_file` path **inside `/inbox`**, read it, + and delete it after. Paths outside the inbox (including symlinks that + resolve outside) are refused: the argument is agent-controlled. 5. Masking rules live in `internal/mask` — keep the entropy/prefix/URL segmentation tests green. 6. **Redemption fails closed** (`internal/redeem`, docs/THREAT-MODEL.md). On any diff --git a/README.ja.md b/README.ja.md index 90e2ed1..47b21ad 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@ Keysmith は、**多層セキュリティモデル**でこれを解決します |---|---|---| | 暗号化された保存 | 保存時に age (X25519) で暗号化 | ディスク上の平文。コンテキストに漏れても blob は暗号文なので無害 | | マスク済みビュー | resources/tools が `sk******ij` 形式で返す | 認証情報がエージェントのコンテキストに入ること | -| ハンドル渡し | `put` は transcript ではなく一時ファイル引数から値を読む | ツール呼び出し transcript 内の平文 | +| ハンドル渡し | `put` は transcript ではなくストアの inbox にあるファイルから値を読む | ツール呼び出し transcript 内の平文 | | 自己修復ローテーション | `scan --rotate` が漏えいを検出し、漏れた値を無効化 | 古い漏えい済み認証情報が使われ続けること | | 短 TTL (Vault) | 動的 DB 認証情報は 1 時間で期限切れ | 漏えいした認証情報の価値が残ること | | Redemption | `keysmith run` がトークンをローカルで差し替え、子プロセスの環境変数へ渡す | 認証情報を「使う」必要があるエージェントが平文を出力してしまうこと。`argv`、transcript、ログ内の平文 | @@ -95,6 +95,7 @@ keysmith set API_KEY < value.txt # stdin から値を読み、shell history keysmith rotate API_KEY 32 # 強力な新しいシークレットを生成して保存 keysmith delete API_KEY # キーを削除 keysmith scan [--rotate] [repo-dir] # git 履歴から漏えいしたシークレットをスキャン +keysmith inbox # MCP の put が値ファイルを受け取るディレクトリ keysmith token API_KEY # セッションに紐づくプレースホルダートークンを発行 keysmith token API_KEY --allow-host api.example.test --allow-path /v1/ # ...宛先に結合する(下記参照) @@ -134,7 +135,7 @@ keysmith -vault http://127.0.0.1:8200 vault-db-creds app-role # 短 TTL |---|---|---| | `list` | すべてのキーとマスク済み値 | 値は平文にならない | | `get` | 1 つのキーのマスク済み値 | 値は平文にならない | -| `put` | シークレットを保存 | 値は `value_file` 引数から読み、一時ファイルは自動削除 | +| `put` | シークレットを保存 | `value_file` はストアの inbox 内のみ受け付け、読み取り後に削除 | | `rotate` | 強力なランダムシークレットを生成して保存 | マスク済み値のみを返す | | `delete` | キーを削除 | - | @@ -192,9 +193,9 @@ flowchart TB - **保存時**: 常に age (X25519) で暗号化され、armor 形式です。平文ファイルはディスク上に存在しません。atomic write (temp + rename) と `0600` 権限を使います。 - **コンテキスト内**: マスク済み値のみです。マスキングは先頭/末尾 2 文字を残し (`sk******ij`)、認証情報を識別できるが露出しない形にします。 -- **Transcript 内**: `put` は平文を引数として受け取りません。一時ファイルパスを読み、読み取り後にファイルを削除します。 +- **Transcript 内**: `put` は平文を引数として受け取りません。ファイルパスを読み、ストアの inbox (`keysmith inbox`) 内のパスのみ受け付け、読み取り後にファイルを削除します。inbox の外は拒否されるため、任意ファイルの読み取りや削除には使えません。 - **使用時**: `keysmith run` はセッションに紐づくトークンをローカルで解決し、値を子プロセスの環境変数へ渡します。値は `argv`、audit log、モデルへのリクエストに到達しません。失敗時はコマンド起動前に中止し、平文フォールバックもトークンのそのまま転送も行いません。結合済みトークンは `--target` も満たす必要があり、宣言された宛先は audit 行に記録されます。 -- **マスキング規則**: キー名マーカー (SECRET/TOKEN/PASSWORD/API_KEY/DSN...)、既知の値プレフィックス (sk-, ghp_, glpat-, xoxb-, JWT...)、高エントロピーな英数字列 (英字と数字が混在する 20 文字以上、Shannon entropy 3.5 以上) を使います。URL 形状の値はセグメントごとにマスクされます。userinfo のパスワードは常にマスクされ、高エントロピーな path/query はマスクされ、host/port は残ります。timeout や retry などの純数字値はマスクされません。 +- **マスキング規則**: キー名マーカー (SECRET/TOKEN/PASSWORD/API_KEY/DSN...)、既知の値プレフィックス (sk-, ghp_, glpat-, xoxb-, JWT...)、高エントロピーな英数字列 (英字と数字が混在する 20 文字以上、Shannon entropy 3.5 以上) を使います。キー名が認証情報を示す場合は、値がヒューリスティックに一致しなくても必ずマスクします (数字を含まないパスフレーズもシークレットです)。機密でないキー (DEBUG, PORT) の設定値はそのまま読めます。URL 形状の値はセグメントごとにマスクされます。userinfo のパスワードは常にマスクされ、高エントロピーな path/query はマスクされ、host/port は残ります。timeout や retry などの純数字値はマスクされません。 ## 開発 diff --git a/README.md b/README.md index e1eabe8..92bfa32 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Keysmith fixes this with a **layered security model**: |---|---|---| | Encrypted residency | age (X25519) encrypted store at rest | Plaintext on disk; even a leaked context is harmless (blobs are ciphertext) | | Masked views | resources/tools return `sk******ij` form | Credentials entering agent context | -| Handle-passing | `put` reads value from a temp file arg, not the transcript | Plaintext in tool-call transcripts | +| Handle-passing | `put` reads the value from a file in the store inbox, not from the transcript | Plaintext in tool-call transcripts | | Self-healing rotation | `scan --rotate` detects leaks, kills the leaked value | Stale leaked credentials keep working | | Short-TTL (Vault) | dynamic DB creds expire in 1h | Leaked credentials become worthless | | Redemption | `keysmith run` substitutes a token locally, into the child's environment | An agent that must *use* a credential being tempted to print it; plaintext in `argv`, transcripts, or logs | @@ -106,6 +106,7 @@ keysmith set API_KEY < value.txt # value from stdin, no shell-history leak keysmith rotate API_KEY 32 # generate + store new strong secret keysmith delete API_KEY # remove a key keysmith scan [--rotate] [repo-dir] # scan git history for leaked secrets +keysmith inbox # directory the MCP put tool accepts files from keysmith token API_KEY # issue a session-bound placeholder token keysmith token API_KEY --allow-host api.example.test --allow-path /v1/ # ...bound to a destination (see below) @@ -157,7 +158,7 @@ keysmith -vault http://127.0.0.1:8200 vault-db-creds app-role # dynamic sh |---|---|---| | `list` | All keys with masked values | Values never plaintext | | `get` | Single key, masked value | Values never plaintext | -| `put` | Store a secret | Value read from `value_file` arg, temp file auto-removed | +| `put` | Store a secret | Reads `value_file` from the store inbox only, then deletes it | | `rotate` | Generate + store a new strong random secret | Returns masked value only | | `delete` | Remove a key | — | @@ -220,10 +221,12 @@ itself, transports decide how agents connect. - **At rest**: always age-encrypted (X25519), armor format. A plaintext file never exists on disk. Atomic writes (temp + rename), `0600` perms. +- **In transcripts**: `put` never takes the plaintext as an argument — it + reads a file path, and only inside the store inbox (`keysmith inbox`); the + file is deleted after it is read. A path outside the inbox is refused, so the + tool cannot be used as an arbitrary-file read or unlink. - **In context**: masked values only. Masking keeps first/last 2 chars (`sk******ij`) so credentials are distinguishable without being revealed. -- **In transcripts**: `put` never takes the plaintext as an argument — it - reads a temp file path and deletes the file after. - **At use time**: `keysmith run` redeems a session-bound token locally and places the value in the child's environment. Values never reach `argv`, the audit log, or a model request; every failure aborts before the command starts @@ -233,6 +236,9 @@ itself, transports decide how agents connect. - **Masking rules**: key-name markers (SECRET/TOKEN/PASSWORD/API_KEY/DSN...), known value prefixes (sk-, ghp_, glpat-, xoxb-, JWT...), and high-entropy alphanumeric runs (≥20 chars mixing letters+digits, Shannon entropy ≥3.5). + A sensitive key name forces masking even when the value does not match a + value heuristic — a passphrase without digits is still a secret. Plain + configuration under a non-sensitive key (DEBUG, PORT) stays readable. URL-shaped values are masked per-segment: userinfo password always masked, high-entropy path/query runs masked, host/port stay clear. Pure-numeric values (timeouts, retries) are never masked. diff --git a/cmd/keysmith/main.go b/cmd/keysmith/main.go index eb70f72..93373e0 100644 --- a/cmd/keysmith/main.go +++ b/cmd/keysmith/main.go @@ -230,6 +230,15 @@ func runCLI(storeDir, vaultAddr string, args []string) error { fmt.Print(leakscan.Format(results)) return nil + case "inbox": + // keysmith inbox — print the directory `put` accepts value files from. + dir := filepath.Join(storeDir, mcp.InboxDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + fmt.Println(dir) + return nil + case "vault-kv-get": // keysmith vault-kv-get KEY (masked by default, --unsafe for plaintext) if len(rest) < 1 { @@ -380,6 +389,7 @@ USAGE: keysmith rotate KEY [len] # generate + store new strong secret keysmith delete KEY # remove a key keysmith scan [--rotate] [repo-dir] # scan git history for leaked secrets + keysmith inbox # print the directory MCP put accepts value files from keysmith token NAME [--ttl 1h] [--allow-host H] [--allow-path P] [--allow-header HN] # issue a session-bound placeholder token keysmith run [--session SID] [--target URL] --env KEY='