UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

252 lines (251 loc) • 13.1 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * `akm secret` command family. Extracted verbatim from src/cli.ts (WS6) so the * God Module shrinks; the `main.subCommands.secret` key and every subcommand's * args/output shape are byte-identical. The ref-resolution helpers * (parseSecretRef / makeSecretRef / resolveSecretPath, plus the shared * findEnvSource) live in src/core/env-secret-ref.ts so env + secret share one * copy. * * `akm secret` manages whole-file secrets under each stash's secrets/ directory. * Unlike env files (.env key/value), the ENTIRE file is the secret value. The bytes * are NEVER written to stdout or structured output. The only value-use path is * `akm secret run` (injected into a child env var). * * `secret path` and `secret remove` were REMOVED in 0.9.0 (R-027 / D-49): an * audit found `path` resolved the ref through the read-side, all-sources * resolver while `remove` resolved it through the write-target resolver, so * `akm secret path <ref>` and `akm secret remove <ref>` could silently name * two DIFFERENT files when a ref existed in more than one stash — inspect one, * delete the other. The owner's ruling was to drop both subcommands rather * than reconcile the resolvers. A ref's file lives at `<bundle>/secrets/<name>` * (matching the ref exactly, e.g. `secrets/deploy-key` -> `secrets/deploy-key` * under a bundle root — `akm bundle list` prints each configured bundle's root * path); locate or delete it directly, or consume its value without touching * disk via `akm secret run <ref> <VAR> -- <command>`. */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { getParsedInvocation } from "../../cli/invocation.js"; import { getStringArg } from "../../cli/parse-args.js"; import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js"; import { decideDangerousEnvInjection } from "../../core/activation-policy.js"; import { deriveCanonicalAssetName } from "../../core/asset/asset-placement.js"; import { loadConfig } from "../../core/config/config.js"; import { makeSecretRef, resolveSecretPath, resolveSecretWriteTarget, sensitiveMarkerPath, withEnvSecretWrite, } from "../../core/env-secret-ref.js"; import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js"; import { appendEvent } from "../../core/events.js"; import { warn } from "../../core/warn.js"; import { resolveSourceEntries } from "../../indexer/search/search-source.js"; import { readStdin } from "../../runtime.js"; import { buildChildEnv } from "./child-env.js"; function parseKeyListFlag(raw) { if (raw === undefined) return undefined; const keys = raw .split(/[,\s]+/) .map((k) => k.trim()) .filter(Boolean); return keys.length > 0 ? keys : undefined; } /** Walk `secrets/` across all stashes, returning one entry per secret file. */ function listSecretsRecursive() { const result = []; for (const source of resolveSourceEntries(undefined, loadConfig())) { const secretsDir = path.join(source.path, "secrets"); if (!fs.existsSync(secretsDir)) continue; const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); continue; } if (!entry.isFile()) continue; if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive")) continue; // A sibling `<name>.sensitive` marker suppresses listing. if (fs.existsSync(sensitiveMarkerPath(full, "secret"))) continue; const canonical = deriveCanonicalAssetName("secret", secretsDir, full); if (!canonical) continue; result.push({ ref: makeSecretRef(canonical, source), path: full }); } }; walk(secretsDir); } return result; } const secretListCommand = defineJsonCommand({ meta: { name: "list", description: "List all secrets across all bundles by name (the file contents are never shown)", }, async run() { output("secret-list", { secrets: listSecretsRecursive() }); }, }); const secretSetCommand = defineJsonCommand({ meta: { name: "set", description: "Create or overwrite a secret. The value is read from stdin by default (never via argv). Use --from-file <path> to import an existing file byte-exact, or --from-env <VAR> to read from an environment variable. Multi-line values are allowed.", }, args: { ref: { type: "positional", description: "Secret ref (flat name, e.g. secrets/deploy-key or just deploy-key; use --path for a subdirectory)", required: true, }, path: { type: "string", description: "Relative subdirectory under secrets/ to place the secret in (e.g. 'team'). The filename comes from the name.", }, "from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" }, "from-env": { type: "string", description: "Read the value from the named environment variable" }, target: { type: "string", description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working bundle.", }, }, async run({ args }) { const { setSecret } = await import("./secret.js"); const { name, absPath, target, ref } = resolveSecretWriteTarget(args.ref, args.target, { subPath: getStringArg(args, "path"), }); const fromEnv = args["from-env"]; const fromFile = args["from-file"]; if (fromEnv !== undefined && fromFile !== undefined) { throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE"); } let value; if (fromFile !== undefined) { if (!fs.existsSync(fromFile)) { throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND"); } value = fs.readFileSync(fromFile); } else if (fromEnv !== undefined) { const envVal = process.env[fromEnv]; if (envVal === undefined) { throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE"); } value = Buffer.from(envVal, "utf8"); } else { if (process.stdin.isTTY) { process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`); } const stdinBuf = await readStdin(); // Strip a single trailing newline so `echo "$TOKEN" | akm secret set` // stores the token without the shell-added newline. Use --from-file for // byte-exact storage of multi-line material (PEM keys, certs). value = Buffer.from(stdinBuf.toString("utf8").replace(/\n$/, ""), "utf8"); } withEnvSecretWrite(target, { type: "secret", name }, "Update", [absPath], () => setSecret(absPath, value)); output("secret-set", { ref }); }, }); const secretRunCommand = defineJsonCommand({ meta: { name: "run", description: "Run a command with a secret's value injected into an env var: `akm secret run <ref> <VAR> -- <command>`. The value is set as $VAR in the child process only. Pass --clean to start the child with a minimal inherited environment instead of the full parent environment.", }, args: { ref: { type: "positional", description: "Secret ref", required: true }, var: { type: "positional", description: "Environment variable name to inject the value into", required: true }, clean: { type: "boolean", description: "Start the child with a minimal inherited environment (PATH/HOME/locale/terminal basics) instead of the full parent environment.", default: false, }, inherit: { type: "string", description: "When used with --clean, also inherit these parent env vars (comma-separated). Ignored without --clean.", }, }, async run({ args }) { // Validate the target env var name FIRST (before the command split) so a // dangerous/invalid name is rejected regardless of how the command is // supplied — and so the failure does not depend on argv parsing. const varName = args.var; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) { throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE"); } const command = getParsedInvocation().passthroughArgs(); if (command.length === 0) { throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>"); } const { name, absPath, source } = resolveSecretPath(args.ref); if (!fs.existsSync(absPath)) { throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`); } const { isDangerousEnvKey } = await import("../lint/env-key-rules.js"); if (isDangerousEnvKey(varName)) { const detail = `"${varName}" is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`; const decision = decideDangerousEnvInjection({ dangerousKeys: [varName], thirdParty: Boolean(source.registryId), }); if (decision === "block") { throw new UsageError(`Refusing to inject a secret from a third-party stash into ${detail}\n` + " Review the secret, then copy it into a first-party secret if you trust it.", "INVALID_FLAG_VALUE"); } warn(`Injecting a secret into ${detail} Injecting anyway (first-party stash).`); } const { readValue } = await import("./secret.js"); const mergedEnv = buildChildEnv(process.env, { clean: args.clean === true, inherit: parseKeyListFlag(args.inherit) ?? [], }); mergedEnv[varName] = readValue(absPath).toString("utf8"); // Audit trail: record access by ref + var name only — never the value. appendEvent({ eventType: "secret_access", ref: makeSecretRef(name, source), metadata: { var: varName }, }); const result = spawnSync(command[0], command.slice(1), { stdio: "inherit", env: mergedEnv, }); if (result.error) { const err = result.error; if (err.code === "ENOENT") { throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm secret run'.`); } if (err.code === "EACCES") { throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`); } throw err; } // R-067: was `process.exit(result.status ?? 0)`, unconditional even on // the success path — it skipped the `finally { await // disposeDispatchResources(); }` cleanup in src/cli.ts's `runCommand`. // Setting `process.exitCode` and returning still exits with the child's // exact status once the event loop drains, but lets cleanup run first — // same pattern `emitJsonError` (src/cli/shared.ts) already established. process.exitCode = result.status ?? 0; return; }, }); export const secretCommand = defineGroupCommand({ meta: { name: "secret", description: "Manage secrets — one standalone sensitive value per file (an API token, a PEM private key, a TLS cert).\n\n" + "Names are visible; the file contents are the value and never appear in structured output. For a group of related configuration loaded together, use `akm env`. A secret's file lives at `<bundle>/secrets/<name>` (`akm bundle list` shows bundle roots) — read or delete it there directly, or consume its value without writing it anywhere via `akm secret run <ref> <VAR> -- <command>`.", }, subCommands: { list: secretListCommand, run: secretRunCommand, set: secretSetCommand, }, // No `defaultRun`: bare `akm secret` is a usage error (exit 2), the canonical // bare-group behavior — owner ruling 12. Run `akm secret list` for what the // bare form used to print. });