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

190 lines (189 loc) • 8.83 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/. /** * Environment asset type (`env`) — whole `.env` file storage. * * An `env` asset holds a GROUP of related CONFIGURATION for an app or service * (URLs, feature flags, and any credentials it needs) in a single `.env` file, * sourced/injected wholesale. Values may or may not be sensitive — akm protects * them all the same. For a single sensitive value used on its own for * authentication (a token, key, or cert), use the `secret` type instead. * * akm does not manage individual keys — edit the `.env` file with your own * editor. Values are quoted/escaped only when necessary and round-trip * through `dotenv`; the shell-load safety guarantee lives on the READ path * (see `buildShellExportScript` + `akm env export`). * * Invariant: nothing from an env file except key NAMES may be written to * stdout, returned through the indexer, the `akm show` renderer, or any * structured output channel. Key NAMES are surfaced for discoverability; * comment text is NOT — real .env files routinely carry commented-out * `KEY=value` lines and free-text notes containing live credentials, so * comments are treated exactly like values. The supported value-load paths * are: * * - `akm env run <ref> -- <command>` — values injected into the child * process env (never via a shell), see `loadEnv` (loaded by * `env-binding.ts#resolveEnvBinding`, then merged into the child env by * `env-cli.ts`). This is the primary path and the only one safe for AI * agents (no values ever reach stdout). For an interactive shell, `akm * env run <ref> -- $SHELL`. * - `akm env export <ref> --out <file>` — write parse-then-reserialized safe * `export KEY='value'` lines to a file (mode 0600) for `source`-ing. Values * are re-emitted single-quoted so a raw `.env` containing `X=$(cmd)` cannot * execute on load. `export` never prints values to stdout (would leak into * an agent's context); `path` prints only the file path. * * Value parsing is delegated to the `dotenv` package. * * Secret-token substitution: env VALUES may embed `${secret:NAME}` tokens, which * are replaced at `env run` time with the value of the sibling `secret:NAME` * asset in the SAME stash (see `resolveSecretTokens`). Substitution applies to * values only, never keys; only the `${secret:...}` form is recognised — * shell-style `${VAR}` / `$VAR` are left untouched. The secret lookup is * injected so this module keeps its narrow dependency surface (dotenv + * core/common) and never reaches into the secret resolver/source machinery. */ import fs from "node:fs"; import path from "node:path"; import dotenv from "dotenv"; import { scanEnvKeyNames, writeFileAtomic } from "../../core/common.js"; import { sensitiveMarkerPath } from "../../core/sensitive-marker-path.js"; export { scanEnvKeyNames }; /** * Read and return ONLY non-secret metadata: key names. * * The function reads the whole file into memory (same as any dotenv parser) * but deliberately does not parse values — the LHS-only regex scanner above * ensures no value content is retained or returned. Comment text is never * returned either: comments routinely contain commented-out `KEY=value` * credentials and free-text secrets, so they never leave this function. */ export function listKeys(envPath) { if (!fs.existsSync(envPath)) return { keys: [] }; const text = fs.readFileSync(envPath, "utf8"); return { keys: scanEnvKeyNames(text) }; } /** * Read all KEY=value pairs from an env file. Intended for programmatic callers * that need to inject values into a process environment. Callers MUST NOT write * the returned values to stdout or any logged output. * * Value parsing (quoting, escapes, multi-line, etc.) is delegated to dotenv. */ export function loadEnv(envPath) { if (!fs.existsSync(envPath)) return {}; const buf = fs.readFileSync(envPath); return dotenv.parse(buf); } /** * Serialise an env file's values as a POSIX shell script of `export KEY='value'` * lines, with single-quote escaping (`'\''`). Every line is an assignment of a * literal string — there is no expansion, command substitution, or * non-assignment content, so `eval`-ing the output is safe regardless of what * the source file contains. * * This is the trust boundary for shell loading: a raw `.env` may contain * `X=$(rm -rf ~)`, which would execute if `source`d directly, but dotenv parses * it to the literal string `$(rm -rf ~)` and we re-emit it single-quoted. This * backs `akm env export <ref> --out <file>` (file-only; never printed to stdout). * * NOTE: `${secret:NAME}` token substitution is intentionally NOT applied here. * The export path emits values single-quoted as literals, so an unsubstituted * `${secret:NAME}` is written verbatim (it would expand to nothing under POSIX * shells, never to the secret). Secret-token resolution is scoped to the * `env run` value-injection path only; see `resolveSecretTokens`. */ export function buildShellExportScript(envPath) { const env = loadEnv(envPath); const lines = []; for (const [key, value] of Object.entries(env)) { // Defence in depth: dotenv already validates key shape, but reject any // key we wouldn't be able to export safely. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; const escaped = value.replace(/'/g, "'\\''"); lines.push(`export ${key}='${escaped}'`); } return lines.length > 0 ? `${lines.join("\n")}\n` : ""; } /** * Matches a `${secret:NAME}` substitution token in an env value. The captured * NAME accepts the same character set as a secret asset name (letters, digits, * `_`, `.`, `/`, `-`). Only this exact form is recognised — shell-style * `${VAR}` and `$VAR` deliberately do not match and are left untouched. */ const SECRET_TOKEN_RE = /\$\{secret:([A-Za-z0-9_./-]+)\}/g; /** * Replace every `${secret:NAME}` token in each value with the corresponding * secret value, looked up via the injected `resolveSecret`. Keys are never * touched. Multiple tokens per value and tokens embedded in larger strings * (e.g. `Bearer ${secret:a}:${secret:b}`) are all substituted. * * `resolveSecret` returns `undefined` for an unknown secret name; such names are * collected into `missing` (de-duplicated, in first-seen order) and their tokens * are left unsubstituted in the returned values. Callers MUST treat a non-empty * `missing` as a hard error and inject NOTHING — never partially inject. * * The lookup is injected so this module does not import the secret * resolver/source machinery directly, preserving its narrow dependency surface. * Resolved secret values must never be logged or printed by callers. */ export function resolveSecretTokens(values, resolveSecret) { const missing = []; const missingSeen = new Set(); const out = {}; const cache = new Map(); for (const [key, value] of Object.entries(values)) { out[key] = value.replace(SECRET_TOKEN_RE, (match, name) => { let resolved = cache.get(name); if (!cache.has(name)) { resolved = resolveSecret(name); cache.set(name, resolved); } if (resolved === undefined) { if (!missingSeen.has(name)) { missingSeen.add(name); missing.push(name); } return match; } return resolved; }); } return { values: out, missing }; } /** Create an empty env file (does nothing if it already exists). */ export function createEnv(envPath) { ensureParentDir(envPath); if (fs.existsSync(envPath)) return; writeFileAtomic(envPath, "", 0o600); } /** * Write (create or overwrite) an env file with the given text content, * atomically at mode 0600. Used to ingest an existing `.env` file * (`env create --from-file` / `--from-stdin`). */ export function writeEnv(envPath, content) { ensureParentDir(envPath); writeFileAtomic(envPath, content, 0o600); } /** Remove an env file (and its `.sensitive` marker, if present). Returns true if it existed. */ export function removeEnv(envPath) { if (!fs.existsSync(envPath)) return false; fs.rmSync(envPath); const marker = sensitiveMarkerPath(envPath, "env"); if (fs.existsSync(marker)) fs.rmSync(marker); return true; } function ensureParentDir(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }