diff --git a/.claude/skills/swamp-vault/SKILL.md b/.claude/skills/swamp-vault/SKILL.md index e7b7a174..b66ef434 100644 --- a/.claude/skills/swamp-vault/SKILL.md +++ b/.claude/skills/swamp-vault/SKILL.md @@ -24,19 +24,19 @@ Correct flow: `swamp vault create --json` → edit config if neede ## Quick Reference -| Task | Command | -| ----------------- | -------------------------------------------------- | -| List vault types | `swamp vault type search --json` | -| Create a vault | `swamp vault create --json` | -| Search vaults | `swamp vault search [query] --json` | -| Get vault details | `swamp vault get --json` | -| Edit vault config | `swamp vault edit ` | -| Store a secret | `swamp vault put KEY=VALUE --json` | -| Store from stdin | `echo "val" \| swamp vault put KEY --json` | -| Store interactive | `swamp vault put KEY` (prompts for value) | -| Get a secret | `swamp vault get --json` | -| List secret keys | `swamp vault list-keys --json` | -| Migrate backend | `swamp vault migrate --to-type ` | +| Task | Command | +| ----------------- | ------------------------------------------------------ | +| List vault types | `swamp vault type search --json` | +| Create a vault | `swamp vault create --json` | +| Search vaults | `swamp vault search [query] --json` | +| Get vault details | `swamp vault get --json` | +| Edit vault config | `swamp vault edit ` | +| Store a secret | `swamp vault put KEY=VALUE --json` | +| Store from stdin | `echo "val" \| swamp vault put KEY --json` | +| Store interactive | `swamp vault put KEY` (prompts for value) | +| Read a secret | `swamp vault read-secret --force --json` | +| List secret keys | `swamp vault list-keys --json` | +| Migrate backend | `swamp vault migrate --to-type ` | ## Repository Structure @@ -158,26 +158,31 @@ agent context or chat history. } ``` -## Get a Secret +## Read a Secret Retrieve a specific secret value from a vault. ```bash -swamp vault get dev-secrets API_KEY --json +# With --force to skip confirmation prompt +swamp vault read-secret dev-secrets API_KEY --force --json + +# Interactive mode prompts before revealing +swamp vault read-secret dev-secrets API_KEY ``` -**Output shape:** +**Output shape (--json):** ```json { - "vault": "dev-secrets", - "key": "API_KEY", + "vaultName": "dev-secrets", + "secretKey": "API_KEY", + "vaultType": "local_encryption", "value": "sk-1234567890" } ``` -**Note:** Use with caution. Secret values are sensitive and should not be logged -or displayed unnecessarily. +In log mode without `--force`, prompts for confirmation before displaying the +value. In `--json` mode, outputs directly without prompting. ## List Secret Keys @@ -223,7 +228,7 @@ at model creation time and prevents rotation or in-workflow refresh: ```bash # WRONG — frozen at creation time -TOKEN=$(swamp vault get my-vault AUTH_TOKEN) +TOKEN=$(swamp vault read-secret my-vault AUTH_TOKEN --force) swamp model create ... --global-arg "token=$TOKEN" # RIGHT — resolved fresh per-step diff --git a/design/vaults.md b/design/vaults.md index 1c67392d..e56f0e04 100644 --- a/design/vaults.md +++ b/design/vaults.md @@ -84,6 +84,38 @@ The expression syntax is: - `key` - The secret identifier within that vault - `value` - The value to store (for put operations) +## CLI Secret Retrieval + +The `swamp vault read-secret` command reads a secret value from a vault via CLI: + +``` +swamp vault read-secret [--force] [--json] +``` + +This calls `VaultService.get()` — the same method used by `vault.get()` CEL +expressions — through a dedicated CLI surface. No new `VaultProvider` interface +methods are required. + +### Safety Model + +- **Log mode**: Prompts for confirmation before revealing the secret. Use + `--force` (`-f`) to skip the prompt. +- **JSON mode**: Outputs directly without prompting (designed for agent/script + consumption). +- **Audit**: Every CLI read emits a `VaultSecretRead` domain event through the + event bus, recording the vault name, type, and secret key accessed. + +### JSON Output + +```json +{ + "vaultName": "my-vault", + "secretKey": "API_KEY", + "vaultType": "local_encryption", + "value": "sk-test-..." +} +``` + ## Sensitive Field Marking (Implemented) Model schemas mark fields as sensitive using Zod's `.meta()` method. When a diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index 1e7a7f4c..9ac58a02 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -30,6 +30,7 @@ import { vaultEditCommand } from "./vault_edit.ts"; import { vaultPutCommand } from "./vault_put.ts"; import { vaultListKeysCommand } from "./vault_list_keys.ts"; import { vaultMigrateCommand } from "./vault_migrate.ts"; +import { vaultReadSecretCommand } from "./vault_read_secret.ts"; import { unknownCommandErrorHandler } from "../unknown_command_handler.ts"; /** @@ -69,6 +70,7 @@ export const vaultCommand = new Command() .command("edit", vaultEditCommand) .command("put", vaultPutCommand) .command("migrate", vaultMigrateCommand) + .command("read-secret", vaultReadSecretCommand) .command("list-keys", vaultListKeysCommand) .command( "list", diff --git a/src/cli/commands/vault_get.ts b/src/cli/commands/vault_get.ts index eafe4f98..8cccc3c5 100644 --- a/src/cli/commands/vault_get.ts +++ b/src/cli/commands/vault_get.ts @@ -56,7 +56,7 @@ export const vaultGetCommand = new Command() throw new UserError( `Unexpected argument: ${extra}\n\n` + "Usage: swamp vault get \n\n" + - "To retrieve a secret value, use: swamp vault list-keys ", + "To retrieve a secret value, use: swamp vault read-secret ", ); } diff --git a/src/cli/commands/vault_read_secret.ts b/src/cli/commands/vault_read_secret.ts new file mode 100644 index 00000000..29c7d5ca --- /dev/null +++ b/src/cli/commands/vault_read_secret.ts @@ -0,0 +1,107 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { Command } from "@cliffy/command"; +import { + consumeStream, + createLibSwampContext, + createVaultReadSecretDeps, + vaultReadSecret, +} from "../../libswamp/mod.ts"; +import { createVaultReadSecretRenderer } from "../../presentation/renderers/vault_read_secret.ts"; +import { + createContext, + type GlobalOptions, + resolveRepoDir, +} from "../context.ts"; +import { requireInitializedRepo } from "../repo_context.ts"; + +// deno-lint-ignore no-explicit-any +type AnyOptions = any; + +async function promptConfirmation(message: string): Promise { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + await Deno.stdout.write(encoder.encode(`${message} [y/N] `)); + + const buf = new Uint8Array(1024); + const n = await Deno.stdin.read(buf); + if (n === null) return false; + + const response = decoder.decode(buf.subarray(0, n)).trim().toLowerCase(); + return response === "y" || response === "yes"; +} + +export const vaultReadSecretCommand = new Command() + .name("read-secret") + .description( + `Read a secret value from a vault. + +In interactive (log) mode, prompts for confirmation before revealing the secret +unless --force is set. In --json mode, outputs the value directly.`, + ) + .example("Read a secret", "swamp vault read-secret my-vault API_KEY --force") + .example( + "Read a secret (JSON output)", + "swamp vault read-secret my-vault API_KEY --json", + ) + .arguments(" ") + .option( + "--repo-dir ", + "Repository directory (env: SWAMP_REPO_DIR)", + ) + .option("-f, --force", "Skip confirmation prompt") + .action(async function ( + options: AnyOptions, + vaultName: string, + key: string, + ) { + const cliCtx = createContext(options as GlobalOptions, [ + "vault", + "read-secret", + ]); + cliCtx.logger.debug`Reading secret from vault: ${vaultName}`; + + const { repoDir, repoContext } = await requireInitializedRepo({ + repoDir: resolveRepoDir(options.repoDir), + outputMode: cliCtx.outputMode, + }); + + if (cliCtx.outputMode === "log" && !options.force) { + const confirmed = await promptConfirmation( + `This will reveal the secret '${key}' from vault '${vaultName}'. Continue?`, + ); + if (!confirmed) { + cliCtx.logger.info`Cancelled.`; + return; + } + } + + const ctx = createLibSwampContext({ logger: cliCtx.logger }); + const deps = createVaultReadSecretDeps(repoDir, repoContext.eventBus); + + const renderer = createVaultReadSecretRenderer(cliCtx.outputMode); + await consumeStream( + vaultReadSecret(ctx, deps, { vaultName, secretKey: key }), + renderer.handlers(), + ); + + cliCtx.logger.debug("Vault read-secret command completed"); + }); diff --git a/src/domain/events/types.ts b/src/domain/events/types.ts index c7557129..67c8130f 100644 --- a/src/domain/events/types.ts +++ b/src/domain/events/types.ts @@ -188,7 +188,8 @@ export type RepositoryEvent = | VaultCreated | VaultUpdated | VaultDeleted - | VaultSecretUpdated; + | VaultSecretUpdated + | VaultSecretRead; /** * Event type discriminator values. @@ -493,6 +494,17 @@ export function createVaultDeleted( }; } +/** + * Emitted when a secret is read from a vault via CLI. + */ +export interface VaultSecretRead extends DomainEvent { + readonly type: "VaultSecretRead"; + readonly vaultId: string; + readonly vaultType: string; + readonly vaultName: string; + readonly secretKey: string; +} + /** * Creates a VaultSecretUpdated event. */ @@ -511,3 +523,22 @@ export function createVaultSecretUpdated( timestamp: new Date(), }; } + +/** + * Creates a VaultSecretRead event. + */ +export function createVaultSecretRead( + vaultId: string, + vaultType: string, + vaultName: string, + secretKey: string, +): VaultSecretRead { + return { + type: "VaultSecretRead", + vaultId, + vaultType, + vaultName, + secretKey, + timestamp: new Date(), + }; +} diff --git a/src/libswamp/mod.ts b/src/libswamp/mod.ts index ddd4091c..54af778a 100644 --- a/src/libswamp/mod.ts +++ b/src/libswamp/mod.ts @@ -496,6 +496,16 @@ export { type VaultListKeysInput, } from "./vaults/list_keys.ts"; +// Vault read-secret operations +export { + createVaultReadSecretDeps, + vaultReadSecret, + type VaultReadSecretData, + type VaultReadSecretDeps, + type VaultReadSecretEvent, + type VaultReadSecretInput, +} from "./vaults/read_secret.ts"; + // Vault migrate operations export { createVaultMigrateDeps, diff --git a/src/libswamp/vaults/read_secret.ts b/src/libswamp/vaults/read_secret.ts new file mode 100644 index 00000000..114f57d2 --- /dev/null +++ b/src/libswamp/vaults/read_secret.ts @@ -0,0 +1,196 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { VaultService } from "../../domain/vaults/vault_service.ts"; +import { createVaultSecretRead } from "../../domain/events/types.ts"; +import type { EventBus } from "../../domain/events/event_bus.ts"; +import { YamlVaultConfigRepository } from "../../infrastructure/persistence/yaml_vault_config_repository.ts"; +import type { LibSwampContext } from "../context.ts"; +import { notFound, type SwampError, validationFailed } from "../errors.ts"; + +import { withGeneratorSpan } from "../../infrastructure/tracing/mod.ts"; + +export interface VaultReadSecretData { + vaultName: string; + secretKey: string; + vaultType: string; + value: string; +} + +export type VaultReadSecretEvent = + | { kind: "resolving" } + | { kind: "completed"; data: VaultReadSecretData } + | { kind: "error"; error: SwampError }; + +export interface VaultReadSecretInput { + vaultName: string; + secretKey: string; +} + +interface VaultConfigInfo { + id: string; + name: string; + type: string; +} + +export interface VaultReadSecretDeps { + findVault: (name: string) => Promise; + listVaultNames: () => Promise; + readSecret: (vaultName: string, secretKey: string) => Promise; + publishSecretRead: ( + vaultId: string, + vaultType: string, + vaultName: string, + secretKey: string, + ) => Promise; +} + +export function createVaultReadSecretDeps( + repoDir: string, + eventBus: EventBus, +): VaultReadSecretDeps { + const vaultConfigRepo = new YamlVaultConfigRepository(repoDir); + let vaultServicePromise: Promise | null = null; + + const getVaultService = () => { + if (!vaultServicePromise) { + vaultServicePromise = VaultService.fromRepository(repoDir); + } + return vaultServicePromise; + }; + + return { + findVault: (name) => vaultConfigRepo.findByName(name), + listVaultNames: async () => { + const all = await vaultConfigRepo.findAll(); + return all.map((v) => v.name); + }, + readSecret: async (vaultName, secretKey) => { + const svc = await getVaultService(); + return await svc.get(vaultName, secretKey); + }, + publishSecretRead: async (vaultId, vaultType, vaultName, secretKey) => { + const event = createVaultSecretRead( + vaultId, + vaultType, + vaultName, + secretKey, + ); + await eventBus.publish(event); + }, + }; +} + +export async function* vaultReadSecret( + ctx: LibSwampContext, + deps: VaultReadSecretDeps, + input: VaultReadSecretInput, +): AsyncIterable { + yield* withGeneratorSpan( + "swamp.vault.read_secret", + {}, + (async function* () { + yield { kind: "resolving" }; + + if (!input.vaultName) { + yield { + kind: "error", + error: validationFailed( + "Missing required argument: vault_name\n\n" + + "Usage: swamp vault read-secret \n\n" + + "Use 'swamp vault search' to see available vaults.", + ), + }; + return; + } + + if (!input.secretKey) { + yield { + kind: "error", + error: validationFailed( + "Missing required argument: key\n\n" + + "Usage: swamp vault read-secret \n\n" + + "Use 'swamp vault list-keys ' to see available keys.", + ), + }; + return; + } + + const vaultConfig = await deps.findVault(input.vaultName); + if (!vaultConfig) { + const names = await deps.listVaultNames(); + if (names.length === 0) { + yield { + kind: "error", + error: notFound( + "Vault", + `'${input.vaultName}'. No vaults are configured.\n` + + `Create a vault using: swamp vault create ${input.vaultName}`, + ), + }; + } else { + yield { + kind: "error", + error: notFound( + "Vault", + `'${input.vaultName}'. Available vaults: ${names.join(", ")}`, + ), + }; + } + return; + } + + let value: string; + try { + value = await deps.readSecret(input.vaultName, input.secretKey); + } catch (err) { + yield { + kind: "error", + error: notFound( + "Secret", + `'${input.secretKey}' in vault '${input.vaultName}'. ` + + `Use 'swamp vault list-keys ${input.vaultName}' to see available keys.\n` + + `Original error: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + }; + return; + } + + await deps.publishSecretRead( + vaultConfig.id, + vaultConfig.type, + vaultConfig.name, + input.secretKey, + ); + ctx.logger.debug`Emitted VaultSecretRead event`; + + yield { + kind: "completed", + data: { + vaultName: input.vaultName, + secretKey: input.secretKey, + vaultType: vaultConfig.type, + value, + }, + }; + })(), + ); +} diff --git a/src/libswamp/vaults/read_secret_test.ts b/src/libswamp/vaults/read_secret_test.ts new file mode 100644 index 00000000..d66bf04b --- /dev/null +++ b/src/libswamp/vaults/read_secret_test.ts @@ -0,0 +1,152 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals } from "@std/assert"; +import { collect } from "../testing.ts"; +import { createLibSwampContext } from "../context.ts"; +import { + vaultReadSecret, + type VaultReadSecretDeps, + type VaultReadSecretEvent, +} from "./read_secret.ts"; + +function makeDeps( + overrides?: Partial, +): VaultReadSecretDeps { + return { + findVault: () => + Promise.resolve({ + id: "vault-1", + name: "my-vault", + type: "local_encryption", + }), + listVaultNames: () => Promise.resolve(["my-vault"]), + readSecret: () => Promise.resolve("sk-test-12345"), + publishSecretRead: () => Promise.resolve(), + ...overrides, + }; +} + +Deno.test("vaultReadSecret: yields resolving then completed with secret value", async () => { + const published: string[] = []; + const deps = makeDeps({ + publishSecretRead: (_id, _type, _name, key) => { + published.push(key); + return Promise.resolve(); + }, + }); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "my-vault", + secretKey: "API_KEY", + }), + ); + + assertEquals(events.length, 2); + assertEquals(events[0], { kind: "resolving" }); + assertEquals(events[1].kind, "completed"); + const completed = events[1] as Extract< + VaultReadSecretEvent, + { kind: "completed" } + >; + assertEquals(completed.data.vaultName, "my-vault"); + assertEquals(completed.data.secretKey, "API_KEY"); + assertEquals(completed.data.vaultType, "local_encryption"); + assertEquals(completed.data.value, "sk-test-12345"); + assertEquals(published, ["API_KEY"]); +}); + +Deno.test("vaultReadSecret: yields error when vault not found", async () => { + const deps = makeDeps({ + findVault: () => Promise.resolve(null), + listVaultNames: () => Promise.resolve(["other-vault"]), + }); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "missing", + secretKey: "key", + }), + ); + + assertEquals(events.length, 2); + assertEquals(events[1].kind, "error"); + const error = events[1] as Extract; + assertEquals(error.error.code, "not_found"); +}); + +Deno.test("vaultReadSecret: yields error when no vaults configured", async () => { + const deps = makeDeps({ + findVault: () => Promise.resolve(null), + listVaultNames: () => Promise.resolve([]), + }); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "missing", + secretKey: "key", + }), + ); + + assertEquals(events[1].kind, "error"); + const error = events[1] as Extract; + assertEquals(error.error.code, "not_found"); +}); + +Deno.test("vaultReadSecret: yields error when vault name is empty", async () => { + const deps = makeDeps(); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "", + secretKey: "key", + }), + ); + + assertEquals(events[1].kind, "error"); + const error = events[1] as Extract; + assertEquals(error.error.code, "validation_failed"); +}); + +Deno.test("vaultReadSecret: yields error when secret key is empty", async () => { + const deps = makeDeps(); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "my-vault", + secretKey: "", + }), + ); + + assertEquals(events[1].kind, "error"); + const error = events[1] as Extract; + assertEquals(error.error.code, "validation_failed"); +}); + +Deno.test("vaultReadSecret: yields error when secret key not found in vault", async () => { + const deps = makeDeps({ + readSecret: () => Promise.reject(new Error("Secret not found")), + }); + const events = await collect( + vaultReadSecret(createLibSwampContext(), deps, { + vaultName: "my-vault", + secretKey: "missing-key", + }), + ); + + assertEquals(events[1].kind, "error"); + const error = events[1] as Extract; + assertEquals(error.error.code, "not_found"); +}); diff --git a/src/presentation/renderers/vault_read_secret.ts b/src/presentation/renderers/vault_read_secret.ts new file mode 100644 index 00000000..20db8f83 --- /dev/null +++ b/src/presentation/renderers/vault_read_secret.ts @@ -0,0 +1,68 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import type { + EventHandlers, + VaultReadSecretEvent, +} from "../../libswamp/mod.ts"; +import type { Renderer } from "../renderer.ts"; +import type { OutputMode } from "../output/output.ts"; +import { getSwampLogger } from "../../infrastructure/logging/logger.ts"; +import { UserError } from "../../domain/errors.ts"; + +const logger = getSwampLogger(["vault", "read-secret"]); + +class LogVaultReadSecretRenderer implements Renderer { + handlers(): EventHandlers { + return { + resolving: () => {}, + completed: (e) => { + logger.info`${e.data.value}`; + }, + error: (e) => { + throw new UserError(e.error.message); + }, + }; + } +} + +class JsonVaultReadSecretRenderer implements Renderer { + handlers(): EventHandlers { + return { + resolving: () => {}, + completed: (e) => { + console.log(JSON.stringify(e.data, null, 2)); + }, + error: (e) => { + throw new UserError(e.error.message); + }, + }; + } +} + +export function createVaultReadSecretRenderer( + mode: OutputMode, +): Renderer { + switch (mode) { + case "json": + return new JsonVaultReadSecretRenderer(); + case "log": + return new LogVaultReadSecretRenderer(); + } +}