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
107 lines (106 loc) • 5.79 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/.
/**
* Env-ref → resolved values, shared between `akm env run` and the workflow
* engine's per-unit env bindings (orchestration plan, *Dispatch context*).
*
* Extracted from `env-cli.ts`'s `runEnvInjected` so the native executor
* REUSES the exact same machinery instead of forking it. Every safety
* invariant carries over unchanged:
*
* - `${secret:NAME}` tokens resolve against the env's own stash; a missing
* secret is a hard error and NOTHING is injected (no partial injection).
* - Known process-hijacking keys (LD_PRELOAD, PATH, …) are blocked for
* third-party-sourced stashes and warned about for first-party ones.
* - An `env_access` audit event records key NAMES only, never values.
* - Resolved values must never be written to stdout or logs by callers.
*/
import fs from "node:fs";
import path from "node:path";
import { decideDangerousEnvInjection } from "../../core/activation-policy.js";
import { assetPathForName } from "../../core/asset/asset-placement.js";
import { isWithin } from "../../core/common.js";
import { makeEnvRef, makeSecretRef, resolveEnvPath } from "../../core/env-secret-ref.js";
import { NotFoundError, UsageError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { isDangerousEnvKey } from "../lint/env-key-rules.js";
import { loadEnv, resolveSecretTokens } from "./env.js";
import { readValue } from "./secret.js";
/**
* Resolve an env ref to its injectable values: load the file, apply key
* filtering, substitute `${secret:NAME}` tokens from the sibling secrets
* directory, enforce the dangerous-key policy, and append the keys-only
* `env_access` audit event.
*/
export function resolveEnvBinding(target, options = {}) {
const warn = options.warn ?? ((message) => process.stderr.write(`warning: ${message}\n`));
const { name, absPath, source } = resolveEnvPath(target);
const envRef = makeEnvRef(name, source);
if (!fs.existsSync(absPath)) {
throw new NotFoundError(`Env not found: ${envRef}`);
}
const allValues = loadEnv(absPath);
// Value-safe key filtering (--only / --except operate on key NAMES only).
let envValues = allValues;
if (options.only && options.except) {
throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
}
if (options.only) {
const wanted = new Set(options.only);
const missing = options.only.filter((k) => !(k in allValues));
if (missing.length > 0) {
warn(`--only key(s) not present in ${envRef}: ${missing.join(", ")}`);
}
envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
}
else if (options.except) {
const excluded = new Set(options.except);
envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
}
// Substitute `${secret:NAME}` tokens with the sibling secret asset in the
// SAME stash. A missing secret is a hard error — inject NOTHING.
const secretsRoot = path.join(source.path, "secrets");
const resolveSecret = (secretName) => {
const secretPath = assetPathForName("secret", secretsRoot, secretName);
// Defense-in-depth: ensure the resolved path stays inside the secrets dir.
if (!isWithin(secretPath, secretsRoot)) {
throw new UsageError(`Secret name "${secretName}" escapes the secrets directory.`);
}
if (!fs.existsSync(secretPath))
return undefined;
// Match `secret run`: read utf8, do not trim (stay consistent with that path).
return readValue(secretPath).toString("utf8");
};
const { values: substituted, missing } = resolveSecretTokens(envValues, resolveSecret);
if (missing.length > 0) {
throw new NotFoundError(`Env "${envRef}" references secret(s) not found in its stash: ${missing.map((n) => makeSecretRef(n)).join(", ")}. Nothing was injected.`, "FILE_NOT_FOUND", `Create the missing secret, e.g. \`akm secret set ${makeSecretRef(missing[0])}\`.`);
}
envValues = substituted;
const keys = Object.keys(envValues);
// Scan injected keys for known process-hijacking variables. The workspace
// activation policy decides block (third-party, registryId set) vs. warn
// (first-party stash, where the operator owns the file).
const dangerous = keys.filter(isDangerousEnvKey);
if (dangerous.length > 0) {
const detail = `Env "${envRef}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
const decision = decideDangerousEnvInjection({
dangerousKeys: dangerous,
thirdParty: Boolean(source.registryId),
allowDangerousEnvKeys: options.allowDangerousEnvKeys,
});
if (decision === "block") {
throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
` Review the file, then copy the values into a first-party env if you trust them, ` +
`or pass --allow-dangerous-env-keys once you have.`, "INVALID_FLAG_VALUE");
}
if (decision === "warn") {
warn(options.allowDangerousEnvKeys && source.registryId
? `${detail} Injecting anyway (--allow-dangerous-env-keys).`
: `${detail} Injecting anyway (first-party stash).`);
}
}
// Audit trail: keys only, never values.
appendEvent({ eventType: "env_access", ref: envRef, metadata: { keys } });
return { ref: envRef, values: envValues, keys };
}