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
157 lines (156 loc) • 8.1 kB
JavaScript
// 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/.
/**
* Decide which exact values must be scrubbed from a task's persisted run log
* (issue #755).
*
* # The gap
*
* All three task target kinds funnel through `persistRunLog`, which applied
* only `redactCredentialPatterns` — credential *shapes* (`Bearer …`, `sk-…`,
* webhook URLs). Prompt- and workflow-target runs additionally redact the exact
* secret values reachable by the run before their output ever reaches the log.
* Command-target runs did not: a scheduled command that echoed a configured
* secret whose value is not credential-shaped persisted it verbatim, into both
* the `.log` file and `logs.db`, for the whole retention window.
*
* # Why the obvious fix is wrong
*
* The issue proposed reusing {@link isEnvPassthroughValueSafeToExpose} — the
* filter the prompt path uses — over the env handed to the child. That filter
* fails CLOSED for any name outside a 22-entry allowlist, which is right where
* it currently runs: the prompt path filters `envPassthrough`, a short list the
* operator explicitly declared. A command task inherits the WHOLE ambient
* environment, so the same rule classifies essentially everything as secret.
* Measured on a developer machine: 127 of 132 variables, 25 of them with
* one-character values (`SHLVL=1`, `OLDPWD=/`, `GIT_TERMINAL_PROMPT=0`).
* Redaction is substring replacement, so those become live needles:
*
* Build finished in 12.4s -> Build finished in [REDACTED]2.[REDACTED]s
* 3 tests passed, 0 failed -> [REDACTED] tests passed, [REDACTED] failed
* wrote dist/index.js (48 KB) -> wrote dist[REDACTED]index.js ([REDACTED]8 KB)
*
* A fix that destroys every command log is not a fix.
*
* # What this does instead
*
* Three sources, and the distinction between them is the whole design:
*
* 1. **Declared by config** — the config names which variables hold
* credentials (`engines.<n>.apiKey: ${VAR}`, `embedding.apiKey`, and the
* implicit `AKM_ENGINE_<NAME>_API_KEY` / `AKM_LLM_API_KEY` /
* `AKM_EMBED_API_KEY` recipes). akm KNOWS these are secret.
* 2. **Declared by the task** — the `redact:` list, names only.
* 3. **Inferred** — a name-shape heuristic over the remaining environment, for
* the ambient credential akm was never told about.
*
* Only (3) is a guess, so only (3) carries {@link MIN_INFERRED_SECRET_LENGTH}.
* A declared secret is redacted at ANY length, because the operator told us
* what it is; applying a floor to declared values would silently stop redacting
* short secrets that are scrubbed today. The floor exists solely to stop a
* *guess* from mangling a log, and 8 clears every real credential format (AWS
* key id 20, GitHub PAT 40, `sk-…` 40+) while excluding the flags and counters
* that a name heuristic occasionally catches.
*
* Note what is deliberately NOT collected: the akm secret store on disk. A
* spawned command sees `process.env`, not akm's stores — a stored secret can
* only be echoed if it is already in the environment, where the rules above
* catch it by name. Walking every bundle's `secrets/` on each task firing would
* cost a recursive readdir plus an unbounded read for values the child cannot
* reach anyway. `redact:` is the escape hatch for a secret injected under a
* name none of the rules recognise.
*/
import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose } from "../core/redaction.js";
import { collectEngineCredentialValues } from "../integrations/agent/engine-resolution.js";
/**
* Shortest value an INFERRED (name-heuristic) match may contribute as a
* redaction needle. Declared secrets bypass this entirely.
*
* Redaction replaces substrings, so a short needle is not merely useless — it
* corrupts unrelated output. Below 8 the noise tier is fully intact (`1`, `0`,
* `/`, `80`, `true`, `xhigh`, `31999`); at 8 a chance collision with ordinary
* log vocabulary is negligible, and every credential format in real use is far
* longer. It also matches the conventional minimum password length, so a
* secret shorter than this is already outside normal policy.
*/
export const MIN_INFERRED_SECRET_LENGTH = 8;
/**
* Environment names whose VALUE is treated as a credential on shape alone.
*
* The keyword must be a whole `_`-delimited word. Anchoring on only one side
* would drag in ordinary configuration from whichever side is left open:
* a leading anchor alone matches `KEYBOARD_LAYOUT` and `AUTHOR`, a trailing one
* matches `MONKEY` and `BYPASS`. Whole-word matching gets `GH_TOKEN`,
* `NPM_AUTH_TOKEN`, `MY_API_KEY`, `DB_PASS` and `AWS_SECRET_ACCESS_KEY` right.
*/
const INFERRED_SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|AUTH)(?:_|$)/i;
/**
* Credential variables whose name is a single glued word, which no
* word-boundary rule can see. Enumerated rather than matched: loosening the
* pattern enough to catch `PGPASSWORD` also catches `MONKEY`.
*
* `PWD` cannot be a keyword above for the same reason it appears here as part
* of `MYSQL_PWD` — on its own it is the working directory.
*/
const KNOWN_SECRET_NAMES = new Set(["PGPASSWORD", "MYSQL_PWD"]);
/** True when the NAME alone marks this variable as holding a credential. */
export function isInferredSecretName(name) {
return KNOWN_SECRET_NAMES.has(name.toUpperCase()) || INFERRED_SECRET_NAME.test(name);
}
/** Resolve `${VAR}` / `$VAR` to the variable NAME, or undefined for anything else. */
function envRefName(spec) {
if (!spec)
return undefined;
const match = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(spec.trim());
return match?.[1];
}
/**
* Every value that must be scrubbed from one task's persisted output.
*
* `declaredNames` comes from the task's `redact:` list. A name that is unset in
* `env` simply contributes nothing — naming a variable you do not currently
* export is not an error.
*/
export function collectTaskLogSensitiveValues(input) {
const { env, config, declaredNames } = input;
const values = new Set();
const addDeclared = (value) => {
if (value === undefined)
return;
const trimmed = value.trim();
// Both spellings: `resolveSecret` does not trim but `resolveCredentialFromEnv`
// does, so a variable with trailing whitespace reaches an output boundary in
// either form depending on which path materialized it.
if (value.length > 0)
values.add(value);
if (trimmed.length > 0)
values.add(trimmed);
};
// (1) Declared by config — engine credentials, via the collector the prompt
// path already uses, plus the embedding key it does not cover.
if (config) {
for (const value of collectEngineCredentialValues(config, env))
values.add(value);
addDeclared(env[envRefName(config.embedding?.apiKey) ?? "AKM_EMBED_API_KEY"]);
}
// (2) Declared by the task's `redact:` list — names only, never values.
for (const name of declaredNames ?? [])
addDeclared(env[name]);
// (3) Inferred from the name shape. The only guessing tier, so the only one
// with a length floor — and still subject to the value-level check that keeps
// an allowlisted name from being treated as secret.
for (const [name, value] of Object.entries(env)) {
if (value === undefined || value.length < MIN_INFERRED_SECRET_LENGTH)
continue;
if (!isInferredSecretName(name))
continue;
if (isEnvPassthroughValueSafeToExpose(name, value))
continue;
values.add(value);
}
// Expands credential-bearing URLs into their embedded components. Can yield
// needles shorter than the floor (a URL's password), which is correct: the
// operator's own value implied them.
return collectSensitiveValues(values);
}