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

305 lines (304 loc) • 16.4 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/. /** * `akm task explain <ref> [input flags]` — read-only task introspection * (spec docs/plans/specs/p2b-input-bindings.md §4.5, §1.7 B-N4). * * Prints the task source path and owning bundle, its input declarations with * defaults, the supplied values WITH PROVENANCE (`default` | `flag` | * `schedule-binding`), the resolved target kind + ref, effective execution * settings with field-level provenance, and schedule bindings. * * Read-only, by construction: this module never reserves a durable attempt * (`src/tasks/run/attempt-lifecycle.ts` is never imported here), never writes * history or a log (`src/tasks/run/task-history.ts` / * `src/tasks/run/task-log.ts` are never imported here), never touches the * scheduler (`src/tasks/scheduler-*.ts` are never imported here), and never * resolves a composed command/persona REF against the local index * (`src/tasks/prepare/prepare.ts`'s own command branch always does, via * `prepareCommandInvocation`'s default `sourceLoader` — an unindexed bundle * would otherwise make a read-only introspection command fail with "Run `akm * index` to build it", which is the wrong shape of dependency for a command * whose whole point is to work on a task the caller just wrote). * * SECRET-FREE BY CONSTRUCTION for the structural bans below — these hold * regardless of any value's shape, because the excluded data never reaches * this module in the first place: * * - a resolved `env:` value never reaches this module at all — the task's * OWN `env:` map is deliberately never read from the parsed document; * - the composed target's ref is read STRUCTURALLY from the already-parsed * document (`document.target.uses.ref` / `"akm/command"`), never from a * rendered command/persona source — so a `run:` command string, * `with.content`, an inline `akm/command` prompt body, or a stored * command's own rendered content is never read, let alone printed; * - effective execution settings (engine/model/timeout) reuse * `resolveExecution` (`src/integrations/agent/execution.ts`) — the SAME * resolution entry point every command/persona invocation goes through — * fed a BLANK inline placeholder command (`createInlineResolvedCommand`) * and an EMPTY command-layer values map, instead of the task's real referenced * command source. This is a deliberate, documented trade-off: a * referenced command's OWN frontmatter overrides (its own `engine:`/ * `model:`, if any) do not contribute a layer here, in exchange for never * needing the index, never reading the referenced file's bytes, and never * risking a leaked `runtime.environment` value (the cascade's own * "current" layer, built here from the TASK's own `execution` overrides * ONLY — engine/model/inference/outputSchema/tools/timeout, deliberately * never `environment`/`workspace`/`agent`, so a portable persona selector * can never demand a persona this module never loads). Only * `engine`/`model`/`runtime.timeoutMs` and the cascade's own * `{layer,kind,via}` provenance map are read back — never * `request.command`, `request.persona`, `request.conversation`, or * `request.runtime.environment`/`.workspace`. * * BEST-EFFORT, NOT A GUARANTEE, for input VALUES: a secret-shaped input * value is redacted only when `detectSecretShapedParams` * (`src/workflows/exec/param-secrets.ts`) recognizes its shape, and that * detector is an explicitly best-effort heuristic — its own doc comment * acknowledges expected false negatives (a short, low-entropy, or * unusually-named credential prints unredacted). A recognized value prints * as the literal string `"<redacted>"`, with its row marked * `redacted: true`, instead of the real value — applied uniformly across * every place a declared or supplied VALUE can appear in this envelope: a * declaration's own `default`, each entry of a declaration's own `enum` * list (checked per-entry, not as one blanked list — code-review finding, * explain.ts:184), a `suppliedInputs` entry regardless of provenance, and a * `schedule[].inputs` entry (code-review finding: the declaration row alone * left the identical value printed verbatim one section over, in * `suppliedInputs`). Do not paste this command's output into an untrusted * place on the assumption that it can never contain a credential. * * Field-level execution provenance is READ from `resolveExecution`'s own * `provenance` — this module is a CONSUMER of the one common resolver, never a * second resolver: it never re-derives engine/model precedence itself. */ import fs from "node:fs"; import { detectAdapterId } from "../../core/adapter/detect-adapter.js"; import { makeBundleRef } from "../../core/asset/asset-ref.js"; import { loadConfig } from "../../core/config/config.js"; import { NotFoundError } from "../../core/errors.js"; import { applyInputDefaults, materializeInputFlags, } from "../../execution/input-contract.js"; import { createInlineResolvedCommand } from "../../execution/resolved-request.js"; import { resolveAdapterConceptOwner } from "../../indexer/lookup/adapter-concept-owner.js"; import { resolveExecution } from "../../integrations/agent/execution.js"; import { isSchedulerRefEnabled } from "../../tasks/activation-config.js"; import { parseTaskSource } from "../../tasks/source/parse-task-source.js"; import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js"; import { TASK_INPUT_DIAGNOSTICS } from "../../tasks/source/task-input-diagnostics.js"; import { validateTaskConceptId, validateTaskId } from "../../tasks/task-id.js"; import { detectSecretShapedParams } from "../../workflows/exec/param-secrets.js"; import { parseTaskRef, resolveTaskReadBundle, taskIdForAdapter } from "./tasks.js"; function isSecretShapedValue(name, value) { return detectSecretShapedParams({ [name]: value }).length > 0; } /** * Code-review finding (explain.ts:175, B-N4): `buildInputDeclarations` * already redacted a secret-shaped DEFAULT, but `buildSuppliedInputs` (and * a v4 schedule entry's own `inputs`, printed verbatim from the source) * echoed the identical value unredacted in the same envelope — a * `sk-live-…` default appeared in full in both output formats. Applied to * every supplied/schedule-binding row regardless of provenance, so the same * best-effort redaction (see this file's header) applies uniformly across * the whole envelope, not just the declaration row. */ function redactIfSecretShaped(name, value) { if (!isSecretShapedValue(name, value)) return { value }; return { value: "<redacted>", redacted: true }; } function declarationType(schema) { return typeof schema.type === "string" ? schema.type : undefined; } function declarationEnum(schema) { return Array.isArray(schema.enum) ? schema.enum : undefined; } /** * Code-review finding (explain.ts:184, B-N4): `buildInputDeclarations` * already redacted a secret-shaped `default`, via {@link redactIfSecretShaped} * applied to the WHOLE default value, but the identical value appearing in * that same declaration's JSON-Schema `enum:` list printed unredacted one * key over — an `enum: [..., "sk-live-…"]` list echoed the credential in * full even though the matching `default:` on the same declaration was * already redacted. Fixed by applying the SAME per-value check * ({@link isSecretShapedValue}) to each entry independently, replacing only * the secret-shaped ones with `"<redacted>"` — deliberately NOT collapsing * the whole list to one `"<redacted>"` sentinel the way a single `default` * value is, since an `enum` list's non-secret alternatives are useful * provenance in their own right and dropping them would over-redact. */ function redactEnum(name, values) { let redacted = false; const out = values.map((value) => { if (!isSecretShapedValue(name, value)) return value; redacted = true; return "<redacted>"; }); return { enum: out, redacted }; } function buildInputDeclarations(contract) { const out = {}; for (const [name, declaration] of Object.entries(contract)) { const typedDeclaration = declaration; const hasDefault = Object.hasOwn(typedDeclaration, "default"); const secretDefault = hasDefault && isSecretShapedValue(name, typedDeclaration.default); const enumValues = declarationEnum(typedDeclaration.schema); const enumRedaction = enumValues !== undefined ? redactEnum(name, enumValues) : undefined; out[name] = { ...(declarationType(typedDeclaration.schema) !== undefined ? { type: declarationType(typedDeclaration.schema) } : {}), ...(enumRedaction !== undefined ? { enum: enumRedaction.enum } : {}), required: typedDeclaration.required, ...(hasDefault ? { default: secretDefault ? "<redacted>" : typedDeclaration.default } : {}), ...(secretDefault || enumRedaction?.redacted ? { redacted: true } : {}), }; } return out; } function buildSuppliedInputs(defaultedInputs, materializedInputs) { const out = {}; for (const [name, value] of Object.entries(defaultedInputs)) { const provenance = Object.hasOwn(materializedInputs, name) ? "flag" : "default"; const redaction = redactIfSecretShaped(name, value); out[name] = { value: redaction.value, provenance, ...(redaction.redacted ? { redacted: true } : {}) }; } return out; } /** The composed target's kind + ref, read STRUCTURALLY off the already-parsed document — never from a rendered command/persona source. */ function targetSection(document) { if (document.target.kind === "run") return { kind: "shell" }; const uses = document.target.uses; return { kind: uses.kind, ref: uses.ref }; } /** True only for the two `uses:` kinds an execution cascade (engine/model resolution) actually applies to. */ function isCommandTarget(document) { return (document.target.kind === "uses" && (document.target.uses.kind === "builtin-command" || document.target.uses.kind === "command")); } /** * The task's own top-level execution overrides (`document.akm`, D2-N7's * home for both a v3 document's authored `akm.*` and a v4 document's * projected `execution` block) as a cascade "current" layer — the exact * non-secret subset `src/tasks/prepare/prepare-support.ts`'s * `currentExecutionValues` also forwards, minus `agent`/`workspace`/ * `environment` (this module never loads a persona and never reads `env:`, * see this file's header). */ function currentExecutionValues(document) { const akm = document.akm; if (!akm) return {}; const out = {}; for (const key of ["engine", "model", "inference", "outputSchema", "tools", "timeout"]) { if (Object.hasOwn(akm, key)) out[key] = akm[key]; } return out; } /** * Effective execution settings + field-level provenance for a command-kind * target — see this file's header for why this is a BLANK-command cascade * call rather than a real command/persona load. */ function resolveExecutionSettings(document) { if (!isCommandTarget(document)) return { provenance: {} }; const current = currentExecutionValues(document); const prepared = resolveExecution({ command: createInlineResolvedCommand({ template: "", content: "" }), config: loadConfig(), commandLayer: { id: "task-explain", values: {} }, ...(Object.keys(current).length > 0 ? { current } : {}), }); const engine = prepared.request.engine; return { engine: { name: engine.name, kind: engine.kind, ...(Object.hasOwn(engine, "platform") ? { platform: engine.platform } : {}), }, ...(prepared.request.model !== undefined ? { model: prepared.request.model } : {}), ...(prepared.request.runtime.timeoutMs !== undefined ? { timeoutMs: prepared.request.runtime.timeoutMs } : {}), provenance: prepared.provenance, }; } /** * Resolve, parse, and project one task asset into an explain envelope with * every secret-shaped value redacted on a best-effort basis (see this * file's header). Read-only: no history write, no scheduler touch, no * execution spawn. */ export async function akmTaskExplain(ref, options = {}) { const parsedRef = parseTaskRef(ref); const bundle = resolveTaskReadBundle(parsedRef.bundle, options.target); const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path); const id = taskIdForAdapter(parsedRef.id, adapterId); if (adapterId === "akm-task") validateTaskConceptId(id); else validateTaskId(id); const taskConceptId = adapterId === "akm" ? `tasks/${id}` : id; const owner = resolveAdapterConceptOwner(bundle.source.path, adapterId, taskConceptId); if (!owner) { throw new NotFoundError(`Task ${JSON.stringify(ref)} was not found in the configured ${JSON.stringify(adapterId)} component.`, "ASSET_NOT_FOUND"); } const sourcePath = owner.path; const yaml = fs.readFileSync(sourcePath, "utf8"); const parsed = parseTaskSource({ yaml, filePath: sourcePath, workspaceRoot: bundle.source.path }); const inputContract = parsed.v4.inputs ?? {}; // Code-review finding (explain.ts:299, B-N4): `akm task run`'s // load-task.ts copies this same materialize -> applyInputDefaults -> // validateInputs ladder, but its own trailing `validateInputs` + // `contractViolation` throw is load-task's OWN enforcement that a task // about to actually EXECUTE never dispatches with a required input still // unmet. `explain` never dispatches anything (this file's header) — it is // read-only introspection, and a task's declared-but-unsupplied required // input is exactly the fact `explain` exists to surface, not a condition // that should make the command refuse to print. Deliberately NOT calling // `validateInputs` here: `materializeInputFlags` above still runs its own // per-flag validation (an unknown flag name still fails UNKNOWN_FLAG, // B-55; a supplied value failing its own declared schema still fails // here), so only the "required and never supplied at all" case is left // unenforced. `buildSuppliedInputs` below naturally renders that case as a // declaration row (always present, carrying `required: true`) with no // corresponding `suppliedInputs` entry, since `defaultedInputs` has no key // for an input with neither a default nor a supplied value. const materializedInputs = materializeInputFlags(inputContract, options.inputFlags ?? [], TASK_INPUT_DIAGNOSTICS); const defaultedInputs = applyInputDefaults(inputContract, materializedInputs); const document = projectTaskSourceV4(parsed.v4); const enabled = isSchedulerRefEnabled(loadConfig(), makeBundleRef(bundle.source.name, taskConceptId)); const schedule = parsed.v4.schedule.map((entry) => ({ ordinal: entry.ordinal, cron: entry.cron, enabled, source: entry.source, inputs: Object.fromEntries(Object.entries(entry.inputs).map(([name, value]) => { const redaction = redactIfSecretShaped(name, value); return [ name, { value: redaction.value, provenance: "schedule-binding", ...(redaction.redacted ? { redacted: true } : {}), }, ]; })), })); return { ref, taskId: id, bundleName: bundle.source.name, sourcePath, sourceVersion: parsed.version, ...(parsed.v4.name !== undefined ? { name: parsed.v4.name } : {}), ...(parsed.v4.description !== undefined ? { description: parsed.v4.description } : {}), target: targetSection(document), inputDeclarations: buildInputDeclarations(inputContract), suppliedInputs: buildSuppliedInputs(defaultedInputs, materializedInputs), execution: resolveExecutionSettings(document), schedule, }; }