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
242 lines (241 loc) • 12.3 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/.
import { getStringArg, parsePositiveIntFlag } from "../../cli/parse-args.js";
import { defineJsonCommand, output } from "../../cli/shared.js";
import { VALID_ADAPTER_IDS } from "../../core/adapter/adapter-ids.js";
import { UsageError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { warn } from "../../core/warn.js";
import { auditStashForDangerousKeys } from "./dangerous-env-audit.js";
import { akmRemove, akmUpdate } from "./installed-stashes.js";
import { akmAdd } from "./source-add.js";
import { addStash, removeStash } from "./source-manage.js";
// ── Shared website-options helper ──────────
export function buildWebsiteOptions(args) {
// getStringArg maps absent/blank to undefined; parsePositiveIntFlag maps
// undefined to undefined — so an unsupplied flag simply omits the key.
const maxPages = parsePositiveIntFlag(getStringArg(args, "max-pages"), "--max-pages");
const maxDepth = parsePositiveIntFlag(getStringArg(args, "max-depth"), "--max-depth");
return { ...(maxPages !== undefined ? { maxPages } : {}), ...(maxDepth !== undefined ? { maxDepth } : {}) };
}
// ── HTTP safety check ─────────────────────────────────────────────────────────
export function shouldWarnOnPlainHttp(ref) {
if (!ref.startsWith("http://"))
return false;
try {
const hostname = new URL(ref).hostname.toLowerCase();
return (hostname !== "localhost" &&
hostname !== "127.0.0.1" &&
hostname !== "0.0.0.0" &&
hostname !== "::1" &&
hostname !== "[::1]" &&
!hostname.endsWith(".localhost"));
}
catch {
return true;
}
}
/**
* Audit a freshly-installed stash for dangerous env keys and decide whether the
* install must be blocked. The scanner and decision policy are shared with
* `bundle update` in dangerous-env-audit.ts.
*/
export async function auditInstalledStashForDangerousKeys(opts) {
const { installedStashRoot, ref, allowDangerousKeys, rollbackTarget, isTTY } = opts;
return auditStashForDangerousKeys({
stashRoot: installedStashRoot,
ref,
allowDangerousKeys,
isTTY,
operation: "install",
rollback: async () => {
try {
await akmRemove({ target: rollbackTarget });
return undefined;
}
catch {
return (`Rollback failed — stash may still be installed at ${installedStashRoot}. ` +
`Remove it manually with: akm bundle remove ${rollbackTarget}`);
}
},
});
}
// ── Command definition ────────────────────────────────────────────────────────
export const addCommand = defineJsonCommand({
meta: {
name: "add",
description: "Add a source (local directory, website, npm package, GitHub repo, git URL, or remote provider)",
},
args: {
ref: {
type: "positional",
description: "Path, URL, or registry ref (website URL, npm package, owner/repo, git URL, or local directory)",
required: true,
},
provider: { type: "string", description: "Provider type (e.g. website, npm). Required for URL sources." },
options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
name: { type: "string", description: "Human-friendly name for the source" },
adapter: {
type: "string",
description: "Override the auto-detected component adapter for a local directory (#909). One of: " +
`${VALID_ADAPTER_IDS.join(", ")}.`,
},
writable: {
type: "boolean",
description: "Mark a git bundle as writable so changes can be pushed back",
default: false,
},
before: { type: "string", description: "Insert the new bundle before this configured bundle" },
after: { type: "string", description: "Insert the new bundle after this configured bundle" },
credential: {
type: "string",
description: "Git HTTPS bearer credential reference ($VAR or secret://name); never a literal token",
},
"max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
"max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
"allow-insecure-transport": {
type: "boolean",
description: "Allow a plain HTTP source URL after explicitly accepting transport substitution risk.",
default: false,
},
"allow-dangerous-env-keys": {
type: "boolean",
description: "Allow reviewed process-hijacking env keys (e.g. LD_PRELOAD, PATH) in the installed bundle.",
default: false,
},
},
async run({ args }) {
const ref = args.ref.trim();
const allowInsecureTransport = args["allow-insecure-transport"];
const allowDangerousKeys = args["allow-dangerous-env-keys"];
if (args.before && args.after)
throw new UsageError("Only one of --before or --after may be used.");
// --provider → declarative bundle source (URL for git/website; bare
// package spec for npm — R-013). Git is materialized below before success;
// other providers remain declarative until bundle update.
if (args.provider) {
if (shouldWarnOnPlainHttp(ref)) {
if (!allowInsecureTransport) {
throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
"Use https:// or pass --allow-insecure-transport if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure-transport` only after confirming the URL is trusted.");
}
warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure-transport was set; an on-path attacker could substitute a malicious payload.");
}
let parsedOptions;
if (args.options) {
try {
const parsed = JSON.parse(args.options);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new UsageError("--options must be a JSON object");
}
parsedOptions = parsed;
}
catch (err) {
if (err instanceof UsageError)
throw err;
throw new UsageError("--options must be valid JSON");
}
}
const result = addStash({
target: ref,
name: args.name,
providerType: args.provider,
options: parsedOptions,
writable: args.writable,
before: args.before,
after: args.after,
credential: args.credential,
});
// Declarative git sources must be materialized and indexed before add
// reports success. This also makes --provider git behave consistently
// with inferred/registry git installs (#968/#970).
if (args.provider === "git" && result.added && result.entry?.name) {
let updated;
try {
updated = await akmUpdate({ target: result.entry.name, allowDangerousEnvKeys: allowDangerousKeys });
}
catch (error) {
removeStash(result.entry.name);
throw error;
}
appendEvent({
eventType: "add",
metadata: {
target: ref,
provider: args.provider,
name: args.name ?? null,
writable: args.writable === true,
},
});
output("add", { ...result, index: updated.index });
return;
}
appendEvent({
eventType: "add",
metadata: { target: ref, provider: args.provider, name: args.name ?? null, writable: args.writable === true },
});
output("add", result);
return;
}
if (shouldWarnOnPlainHttp(ref)) {
if (!allowInsecureTransport) {
throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
"Use https:// or pass --allow-insecure-transport if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure-transport` only after confirming the URL is trusted.");
}
warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure-transport was set; an on-path attacker could substitute a malicious payload.");
}
const websiteOptions = buildWebsiteOptions(args);
const result = await akmAdd({
ref,
name: args.name,
options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
writable: args.writable,
adapter: args.adapter,
before: args.before,
after: args.after,
credential: args.credential,
});
appendEvent({
eventType: "add",
metadata: {
target: ref,
name: args.name ?? null,
writable: args.writable === true,
},
});
// ── Post-install env key audit ──────────────────────────────────────────
// Resolve the stash root from the install result and scan any env files
// for dangerous env var keys. When findings are present the install is
// gated: TTY → interactive confirmation prompt; non-TTY without
// --allow-dangerous-env-keys → hard failure (exit 1). Pass the dedicated
// flag to skip the prompt non-interactively.
const installedStashRoot = result.installed?.stashRoot ??
(result.sourceAdded && "stashRoot" in result.sourceAdded ? result.sourceAdded.stashRoot : undefined);
if (installedStashRoot) {
// Use the canonical installed id (most reliably resolved by akmRemove) rather
// than the raw user-supplied ref which may not match after URL normalisation.
const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
// The audit RETURNS its decision; we decide the exit outcome here,
// OUTSIDE any catch, so the abort cannot be lost to a swallowed
// exception (C3). F4: `process.exitCode = …; return;` instead of
// `process.exit(...)` — the DANGEROUS_ENV_KEY error envelope has
// already been written to stderr by the audit itself (or is about to
// be), so an explicit `return` here (skipping the success `output("add",
// …)` below) is load-bearing, not merely cosmetic: it is what stops a
// blocked install from ALSO printing a success envelope.
const decision = await auditInstalledStashForDangerousKeys({
installedStashRoot,
ref,
allowDangerousKeys,
rollbackTarget,
isTTY: process.stdin.isTTY === true,
});
if (decision.blocked) {
process.exitCode = decision.exitCode;
return;
}
}
output("add", result);
},
});