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

572 lines (571 loc) • 23.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/. const ENV_PASSTHROUGH_REDACTION_POLICY = { HOME: "path", PATH: "path", USER: "identifier", LANG: "identifier", LC_ALL: "identifier", TERM: "identifier", TMPDIR: "path", SYSTEMROOT: "path", COMSPEC: "path", PATHEXT: "path", WINDIR: "path", TEMP: "path", TMP: "path", AKM_EVENT_SOURCE: "identifier", OPENCODE_CONFIG: "path", CLAUDE_CONFIG: "path", CODEX_CONFIG: "path", XDG_CONFIG_HOME: "path", XDG_DATA_HOME: "path", XDG_CACHE_HOME: "path", XDG_STATE_HOME: "path", AWS_PROFILE: "identifier", AWS_REGION: "identifier", LLM_MODEL: "identifier", LLM_BASE_URL: "url", }; /** Environment names whose ordinary values identify runtime configuration rather than credentials. */ export const ENV_PASSTHROUGH_REDACTION_ALLOWLIST = new Set(Object.keys(ENV_PASSTHROUGH_REDACTION_POLICY)); const SIGNED_QUERY_KEYS = new Set([ "accesskey", "accesskeyid", "accesstoken", "actortoken", "apikey", "assertion", "authorization", "authorizationcode", "authreqid", "clientassertion", "clientsecret", "code", "codeverifier", "credential", "devicecode", "googleaccessid", "idtoken", "idtokenhint", "initialaccesstoken", "key", "loginhinttoken", "logouthint", "logouttoken", "nonce", "oauthcode", "oauthtoken", "oauthverifier", "password", "refreshtoken", "registrationaccesstoken", "requesturi", "response", "secret", "sessiontoken", "sharedaccesssignature", "sig", "signature", "softwarestatement", "state", "subjecttoken", "token", "usercode", "verifier", "xamzcredential", "xamzsecuritytoken", "xamzsignature", "xgoogcredential", "xgoogsignature", ]); function normalizedQueryKey(key) { return key.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); } function collectCredentialParameters(params, values) { let found = false; for (const [key, value] of params) { if (!SIGNED_QUERY_KEYS.has(normalizedQueryKey(key))) continue; found = true; if (value) values?.add(value); } return found; } function addEncodedCredentialVariants(rawValue, values) { if (!rawValue || !values) return; values.add(rawValue); let decoded; try { decoded = decodeURIComponent(rawValue.replaceAll("+", " ")); } catch { return; } if (!decoded) return; values.add(decoded); const encoded = encodeURIComponent(decoded); values.add(encoded); values.add(encoded.replaceAll("%20", "+")); values.add(encoded.replace(/%[0-9A-F]{2}/g, (sequence) => sequence.toLowerCase())); } function collectEncodedCredentialParameters(raw, values) { let found = false; for (const part of raw.split("&")) { const separator = part.indexOf("="); if (separator < 0) continue; const rawKey = part.slice(0, separator); const rawValue = part.slice(separator + 1); let key = rawKey; try { key = decodeURIComponent(rawKey.replaceAll("+", " ")); } catch { // Keep the raw key so malformed credential-shaped input still fails closed. } if (!SIGNED_QUERY_KEYS.has(normalizedQueryKey(key))) continue; found = true; addEncodedCredentialVariants(rawValue, values); } return found; } function collectCredentialFragment(fragment, values) { const directDecoded = fragment.includes("=") && collectCredentialParameters(new URLSearchParams(fragment), values); const directEncoded = fragment.includes("=") && collectEncodedCredentialParameters(fragment, values); let found = directDecoded || directEncoded; const nestedQuery = fragment.indexOf("?"); if (nestedQuery >= 0) { const query = fragment.slice(nestedQuery + 1); const decoded = collectCredentialParameters(new URLSearchParams(query), values); const encoded = collectEncodedCredentialParameters(query, values); found = decoded || encoded || found; } return found; } function inspectCredentialBearingUrl(value, values) { const trimmed = value.trim(); if (!trimmed) return false; try { const url = new URL(trimmed); let found = false; for (const userInfo of [url.username, url.password]) { if (!userInfo) continue; found = true; values?.add(userInfo); try { values?.add(decodeURIComponent(userInfo)); } catch { values?.add(userInfo); } } const decodedQuery = collectCredentialParameters(url.searchParams, values); const encodedQuery = collectEncodedCredentialParameters(url.search.slice(1), values); found = decodedQuery || encodedQuery || found; return collectCredentialFragment(url.hash.slice(1), values) || found; } catch { // Fail closed for malformed URL-like values carrying the same credential shapes. let found = /^[a-z][a-z0-9+.-]*:\/\/[^/\s?#]*@/i.test(trimmed); const query = trimmed.indexOf("?"); const fragment = trimmed.indexOf("#"); if (query >= 0) { const queryText = trimmed.slice(query + 1, fragment >= 0 ? fragment : undefined); const decodedQuery = collectCredentialParameters(new URLSearchParams(queryText), values); const encodedQuery = collectEncodedCredentialParameters(queryText, values); found = decodedQuery || encodedQuery || found; } if (fragment >= 0) { const fragmentText = trimmed.slice(fragment + 1); found = collectCredentialFragment(fragmentText, values) || found; } return found; } } /** Collect exact secrets plus decoded values embedded in credential-bearing URLs. */ export function collectSensitiveValues(rawValues) { const values = new Set(); for (const value of rawValues) { if (value === undefined || value.length === 0) continue; values.add(value); const trimmed = value.trim(); if (inspectCredentialBearingUrl(value, values) && trimmed) values.add(trimmed); } return [...values]; } /** * Decide whether an allowlisted passthrough value may cross an output boundary. * The name allowlist never overrides value inspection: URL userinfo and signed * query credentials remain secret even under ordinarily non-secret names. */ export function isEnvPassthroughValueSafeToExpose(name, value) { if (value === undefined) return true; const policy = ENV_PASSTHROUGH_REDACTION_POLICY[name]; if (!policy) return false; const classifiedPolicy = policy; switch (classifiedPolicy) { case "identifier": case "path": case "url": return !inspectCredentialBearingUrl(value); default: { const exhaustive = classifiedPolicy; return exhaustive; } } } function utf8SequenceLength(firstByte) { if (firstByte <= 0x7f) return 1; if (firstByte >= 0xc2 && firstByte <= 0xdf) return 2; if (firstByte >= 0xe0 && firstByte <= 0xef) return 3; if (firstByte >= 0xf0 && firstByte <= 0xf4) return 4; return 0; } function normalizeEncodedText(value, plusAsSpace) { let text = ""; const starts = new Int32Array(value.length); let mappingLength = 0; const append = (decoded, start) => { text += decoded; for (let index = 0; index < decoded.length; index++) { starts[mappingLength] = start; mappingLength++; } }; for (let index = 0; index < value.length;) { if (value[index] === "%" && /^[0-9a-f]{2}$/i.test(value.slice(index + 1, index + 3))) { const start = index; const firstByte = Number.parseInt(value.slice(index + 1, index + 3), 16); const sequenceLength = utf8SequenceLength(firstByte); if (sequenceLength > 0) { const bytes = [firstByte]; let cursor = index + 3; while (bytes.length < sequenceLength && value[cursor] === "%" && /^[0-9a-f]{2}$/i.test(value.slice(cursor + 1, cursor + 3))) { bytes.push(Number.parseInt(value.slice(cursor + 1, cursor + 3), 16)); cursor += 3; } if (bytes.length === sequenceLength) { try { const decoded = new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)); index = cursor; append(decoded, start); continue; } catch { // Treat invalid UTF-8 percent sequences as literal text. } } } } const start = index; const codePoint = value.codePointAt(index); if (codePoint === undefined) break; const character = String.fromCodePoint(codePoint); index += character.length; append(plusAsSpace && character === "+" ? " " : character, start); } return { text, starts, sourceLength: value.length }; } function addMappedMatches(coverageDelta, haystack, needle) { if (!needle) return; let offset = 0; while (offset <= haystack.text.length - needle.length) { const match = haystack.text.indexOf(needle, offset); if (match < 0) break; const start = haystack.starts[match]; const normalizedEnd = match + needle.length; const end = normalizedEnd < haystack.text.length ? haystack.starts[normalizedEnd] : haystack.sourceLength; coverageDelta[start] = coverageDelta[start] + 1; coverageDelta[end] = coverageDelta[end] - 1; offset = match + Math.max(needle.length, 1); } } /** * Mark every occurrence of `needle` in `text` — no encoding normalization, for * text that contains neither `%` nor `+` and so cannot carry an encoded form. * * Matching against the ORIGINAL text (rather than an accumulator being rewritten * in place) is the whole point: see {@link redactSensitiveText}. */ function addPlainMatches(coverageDelta, text, needle) { if (!needle) return; let offset = 0; while (offset <= text.length - needle.length) { const match = text.indexOf(needle, offset); if (match < 0) break; coverageDelta[match] = coverageDelta[match] + 1; coverageDelta[match + needle.length] = coverageDelta[match + needle.length] - 1; offset = match + needle.length; } } /** * Name components (after splitting an identifier on `_`/`-` and camelCase * boundaries) that mark a `NAME=value` / `NAME: value` pair as * credential-shaped regardless of length — the free-text-pattern sibling of * `isInferredSecretName` in `src/tasks/log-redaction.ts`, which does the same * job for environment variable names. Bare-word matching only: this * deliberately does NOT flag "author" ("auth" is not a whole word/component * of "author", the same anchoring reasoning `isInferredSecretName` documents) * nor prose like "token budget" (no `=`/`:` for the pattern below to anchor * on in the first place). */ const CREDENTIAL_KEY_WORDS = new Set(["password", "passwd", "secret", "token", "auth", "credential", "credentials"]); /** Split an identifier into lowercase words at `_`/`-` and camelCase boundaries. */ function identifierWords(identifier) { return identifier .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .toLowerCase() .split(/[_-]+/) .filter(Boolean); } /** * True when `identifier` names a credential by shape: a whole word from * {@link CREDENTIAL_KEY_WORDS}, or `api[_-]?key` / `private[_-]?key` in any * of their glued, snake_case, kebab-case or camelCase spellings. */ function isCredentialLikeKeyName(identifier) { const lower = identifier.toLowerCase(); if (lower.includes("apikey") || lower.includes("privatekey")) return true; const words = identifierWords(identifier); for (let i = 0; i < words.length; i++) { const word = words[i]; if (CREDENTIAL_KEY_WORDS.has(word)) return true; if ((word === "api" || word === "private") && words[i + 1] === "key") return true; } return false; } /** * `NAME=value` / `NAME: value`, name captured in group 1, separator in group * 2. Source string (not a shared `RegExp`, which would carry `lastIndex` * state across calls) for {@link redactNamedCredentialValues} to instantiate * fresh each call. */ const NAMED_CREDENTIAL_VALUE_SOURCE = String.raw `\b([A-Za-z][A-Za-z0-9_-]*)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;"']+)`; /** * Redact `NAME=value` / `NAME: value` pairs whose name is credential-shaped * (see {@link isCredentialLikeKeyName}). * * Not a plain `input.replace(re, fn)`: the greedy unquoted-value alternative * happily matches straight through a NESTED assignment when a non-credential * name comes first with no separator of its own — `"config: DB_PASSWORD=…"` * matches name `config` with value `DB_PASSWORD=…` (one contiguous * whitespace-free run), and a plain global replace treats that as ONE * consumed, non-matching span and never revisits `DB_PASSWORD=…` on its own, * silently leaking the credential. Rejecting a non-credential candidate here * instead rewinds the scan to one character past where IT started, so the * engine keeps looking and still finds the real assignment nested inside. */ function redactNamedCredentialValues(input) { const re = new RegExp(NAMED_CREDENTIAL_VALUE_SOURCE, "g"); let result = ""; let cursor = 0; let match = re.exec(input); while (match !== null) { const [full, name, sep] = match; if (isCredentialLikeKeyName(name)) { result += `${input.slice(cursor, match.index)}${name}${sep}[REDACTED]`; cursor = match.index + full.length; re.lastIndex = cursor; } else { re.lastIndex = match.index + 1; } match = re.exec(input); } return result + input.slice(cursor); } /** * Redact credential-shaped substrings from arbitrary text by pattern alone — * unlike {@link redactSensitiveText}, which requires the exact secret value * up front, this catches credentials no caller ever knew to list. No * truncation is applied; callers that need a length cap (e.g. * {@link redactErrorBody}) apply it themselves. * * Targets: * - `Bearer <token>` headers echoed back by a provider * - `sk-…` / `sk_…` style API keys (OpenAI / Anthropic-shaped) * - `key-…` / `key_…` shorthand keys * - `"api_key": "…"` / `"apiKey": "…"` JSON fields * - Discord webhook URLs (`discord.com/api/webhooks/<id>/<token>`) — the id * is kept, only the token segment is redacted * - Slack incoming-webhook URLs (`hooks.slack.com/services/<team>/<channel>/<token>`) * — the team/channel ids are kept, only the trailing token is redacted * - PEM private-key blocks (`-----BEGIN … PRIVATE KEY-----…-----END … PRIVATE KEY-----`) * — the BEGIN/END markers are kept, only the body is redacted * - JWTs (`eyJ…….…….…`) * - GitHub tokens (`ghp_`/`gho_`/`ghs_`/`ghu_`/`ghr_…`) * - Slack tokens (`xoxa-`/`xoxb-`/`xoxp-`/`xoxr-…`) * - AWS access key ids (`AKIA…`) * - `scheme://user:pass@host` URLs — the scheme and host are kept, only the * userinfo is redacted * - `NAME=value` / `NAME: value` where `NAME` names a credential by shape * (password, passwd, secret, token, auth, credential(s), api/private key * — see {@link isCredentialLikeKeyName}) — the name is kept, only the * value is redacted */ export function redactCredentialPatterns(input) { if (!input) return ""; const patterned = input // Bearer tokens (case-insensitive) .replace(/\bBearer\s+[A-Za-z0-9._\-+/=]+/gi, "Bearer [REDACTED]") // sk-/sk_ style keys .replace(/\bsk[-_][A-Za-z0-9._-]{6,}/g, "[REDACTED]") // key-/key_ shorthand keys .replace(/\bkey[-_][A-Za-z0-9._-]{6,}/g, "[REDACTED]") // JSON-style "api_key": "...", "apiKey": "...", "api-key": "..." .replace(/("(?:api[_-]?key|apiKey|authorization|token)"\s*:\s*")([^"]*)(")/gi, "$1[REDACTED]$3") // Discord webhook URLs: keep the webhook id, redact the token segment. .replace(/(discord(?:app)?\.com\/api\/webhooks\/\d+\/)[A-Za-z0-9_-]+/gi, "$1[REDACTED]") // Slack incoming-webhook URLs: keep the team/channel ids, redact the token. .replace(/(hooks\.slack\.com\/services\/[A-Za-z0-9]+\/[A-Za-z0-9]+\/)[A-Za-z0-9]+/gi, "$1[REDACTED]") // PEM private-key blocks: keep the BEGIN/END markers, redact the body. .replace(/(-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z0-9 ]*PRIVATE KEY-----)/g, "$1[REDACTED]$2") // JWTs: three dot-separated base64url segments; the header always // starts "eyJ" (base64 of `{"`). .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") // GitHub tokens (ghp_/gho_/ghs_/ghu_/ghr_...) .replace(/\bgh[posur]_[A-Za-z0-9]{20,255}\b/g, "[REDACTED]") // Slack tokens (xoxa-/xoxb-/xoxp-/xoxr-...) .replace(/\bxox[abpr]-[A-Za-z0-9-]+\b/g, "[REDACTED]") // AWS access key ids .replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]") // URLs carrying `user:pass@` userinfo: keep the scheme and host. .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); // NAME=value / NAME: value for credential-shaped names. Not a plain // `.replace()` in the chain above — see `redactNamedCredentialValues`. return redactNamedCredentialValues(patterned); } /** * Replace exact sensitive values in text. * * Every match is located against the ORIGINAL text and the result is emitted * once, so overlapping matches merge into a single `[REDACTED]` and no needle * can ever match inside a token an earlier needle produced. * * That last property is load-bearing. This function used to take a `replaceAll` * fast path that chained over a *mutating* accumulator, which meant any needle * drawn from the letters of `[REDACTED]` re-matched the tokens already injected * and the output grew geometrically: `redactSensitiveText("a".repeat(50), * ["a","E","D","T","C","R"])` returned 32,450 characters — 649x the input. On a * path where the needle set is derived from configuration or the environment, * that is a memory-exhaustion hazard reachable from ordinary command output. * The encoded-form path never had the bug because it always worked this way. */ /** Max characters of a provider error body worth surfacing in a message. */ const ERROR_BODY_MAX_LEN = 200; /** * Make an HTTP error body safe to put in an error message: pattern-redact * credential shapes, then clip. Provider bodies can echo the credential that * was sent and can be megabytes of HTML, and these messages travel — into * `--json` output and agent transcripts. * * Lives here rather than beside one transport because every HTTP client in the * codebase needs it; the embeddings transport originally lacked it and leaked * raw 10 MB bodies into error messages. */ export function redactErrorBody(input) { if (!input) return ""; let out = redactCredentialPatterns(input); if (out.length > ERROR_BODY_MAX_LEN) { out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`; } return out; } export function redactSensitiveText(text, sensitiveValues) { const values = [...new Set(sensitiveValues)] .filter((value) => value.length > 0) .sort((a, b) => b.length - a.length || a.localeCompare(b)); if (values.length === 0) return text; const coverageDelta = new Int32Array(text.length + 1); if (text.includes("%") || text.includes("+")) { // Percent-/plus-encoded text: match needle and haystack in their decoded // forms, mapping hits back to source offsets. const addMatchesForMode = (plusAsSpace) => { const haystack = normalizeEncodedText(text, plusAsSpace); for (const value of values) { addMappedMatches(coverageDelta, haystack, normalizeEncodedText(value, plusAsSpace).text); } }; addMatchesForMode(false); if (text.includes("+")) addMatchesForMode(true); } else { for (const value of values) addPlainMatches(coverageDelta, text, value); } let redacted = ""; let coverage = 0; let offset = 0; let found = false; for (let index = 0; index <= text.length; index++) { const wasCovered = coverage > 0; coverage += coverageDelta[index]; const isCovered = coverage > 0; if (!wasCovered && isCovered) { redacted += `${text.slice(offset, index)}[REDACTED]`; found = true; } else if (wasCovered && !isCovered) { offset = index; } } return found ? redacted + text.slice(offset) : text; } /** Recursively redact string leaves before a structured value crosses a durable/output boundary. */ export function redactSensitiveValue(value, sensitiveValues) { const values = [...sensitiveValues]; const redact = (entry) => { if (typeof entry === "string") return redactSensitiveText(entry, values); if (Array.isArray(entry)) return entry.map(redact); if (entry && typeof entry === "object") { const out = {}; for (const [key, child] of Object.entries(entry)) { const redactedKey = redactSensitiveText(key, values); // Two DISTINCT keys can redact to the same string (`{a, b, ab}` under // needles `a`/`b` all collapse toward `[REDACTED]`). Building this with // `Object.fromEntries` kept only the last of each colliding group, so a // field was silently DROPPED rather than redacted — data loss disguised // as redaction. Suffix instead: the value stays, the key stays hidden. let finalKey = redactedKey; for (let n = 2; Object.hasOwn(out, finalKey); n++) finalKey = `${redactedKey} (${n})`; out[finalKey] = redact(child); } return out; } return entry; }; return redact(value); }