diff --git a/packages/coding-agent/.changes/eng-5338-project-skill-trust.md b/packages/coding-agent/.changes/eng-5338-project-skill-trust.md new file mode 100644 index 0000000000..d0b6b038e8 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5338-project-skill-trust.md @@ -0,0 +1 @@ +- Fixed project Python skills (`.prime/agent/skills/*/pyproject.toml`) being built and imported at startup without consent: they now require a persisted per-project trust decision (interactive prompt or `/trust-project-skills`), stay markdown-only until trusted, and install into a per-project kernel venv instead of the shared `~/.prime/agent/kernel-venv`. diff --git a/packages/coding-agent/docs/rlm.md b/packages/coding-agent/docs/rlm.md index 27653930fd..3dbd7be479 100644 --- a/packages/coding-agent/docs/rlm.md +++ b/packages/coding-agent/docs/rlm.md @@ -153,4 +153,6 @@ This keeps credentials, provider execution, transcript writes, worker routing, a The Python kernel runs model-generated Python and project commands with the worker's operating-system permissions. It is a durable control environment, not a security sandbox. Review third-party Python skills and use an external sandbox or restricted environment for untrusted repositories and instructions. +Python skills shipped inside the opened repository are not installed or imported until you trust the project (see [Project Skill Trust](skills.md#project-skill-trust)); once trusted they live in a per-project kernel venv rather than the shared one. + For implementation details, see [RLM Runtime Architecture](rlm-runtime.md). diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index ef41c05f27..3091aa8665 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -167,6 +167,16 @@ help(web_search) Python skills are installed editable into the kernel venv during kernel setup. By default this is `~/.prime/agent/kernel-venv`; set `PRIME_AGENT_KERNEL_VENV` to override it. If `pyproject.toml` changes, Prime Agent rebuilds the kernel venv so dependency changes are picked up. +### Project Skill Trust + +A project Python skill (`.prime/agent/skills//pyproject.toml` inside the repository you open, or a Python skill added by the project's `.prime/agent/settings.json`) is code from that repository. Installing it runs its build backend and importing it runs its module code in the kernel, so Prime Agent does neither until you trust the project: + +- Interactive sessions ask once at startup: **Trust and install**, **Not now** (ask again next session), or **Never for this project**. +- Sessions without a UI (`-p`/`--json`, ACP, RLM subagents) never prompt; an undecided or denied project stays untrusted. +- Until trusted, the skill is still listed and its `SKILL.md` can be read, but it is exposed as a markdown skill: no `python_import`, nothing installed, nothing imported. +- `/trust-project-skills on|off|reset|status` changes the decision later and reloads. Decisions are stored per canonical project path in `~/.prime/agent/project-skill-trust.json`. +- Trusted project skills are installed into a per-project kernel venv (`~/.prime/agent/kernel-venv-projects/-`) instead of the shared `kernel-venv`, so project packages and their dependencies never persist into sessions in other projects. User-level skills (`~/.prime/agent/skills`) and built-in skills keep using the shared venv. + If you set `PRIME_AGENT_KERNEL_PYTHON`, Prime Agent does not install packages into that environment. The Python must already have a current `prime-agent-runtime` and the default runtime packages installed. Missing Python skill imports are disabled with a warning and calling the skill raises a `RuntimeError`. ### Optional CLI Command diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 249ffc0773..18d491c587 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -10,9 +10,11 @@ import { AuthStorage } from "./auth-storage.js"; import type { AgentAutonomousConfig } from "./autonomous.js"; import type { AgentRlmHeartbeatController } from "./cron-jobs.js"; import { createHerdrAgentStateExtension } from "./extensions/builtin/herdr-agent-state.js"; +import { createProjectSkillTrustExtension } from "./extensions/builtin/project-skill-trust.js"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; import { McpManager } from "./mcp/mcp-manager.js"; import { ModelRegistry } from "./model-registry.js"; +import { createProjectSkillTrustStore, type ProjectSkillTrustStore } from "./project-skill-trust.js"; import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js"; import type { SubagentRuntimeHost } from "./rlm-runtime.js"; import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js"; @@ -42,6 +44,8 @@ export interface CreateAgentSessionServicesOptions { */ noBuiltinHerdrReporter?: boolean; telemetryDisabled?: true; + /** Trust store for project Python skills. Default: file store in agentDir. */ + projectSkillTrust?: ProjectSkillTrustStore; } export interface AgentSessionCreationOptions { @@ -89,6 +93,7 @@ export interface AgentSessionServices { modelRegistry: ModelRegistry; resourceLoader: ResourceLoader; mcpManager: McpManager; + projectSkillTrust: ProjectSkillTrustStore; diagnostics: AgentSessionRuntimeDiagnostic[]; } @@ -167,9 +172,16 @@ export async function createAgentSessionServices( // noExtensions is a full opt-out: it disables the built-in reporter too, // not just discovered extension files. const skipHerdrReporter = options.noBuiltinHerdrReporter || options.resourceLoaderOptions?.noExtensions; - const builtinExtensionFactories = skipHerdrReporter - ? [] - : [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())]; + const projectSkillTrust = options.projectSkillTrust ?? createProjectSkillTrustStore(agentDir); + const builtinExtensionFactories = [ + // Always available: it is the only way to change a persisted project skill + // trust decision from inside a session, including under --no-extensions. + createProjectSkillTrustExtension({ + store: projectSkillTrust, + getSkills: () => resourceLoader.getSkills().skills, + }), + ...(skipHerdrReporter ? [] : [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())]), + ]; const resourceLoader: DefaultResourceLoader = new DefaultResourceLoader({ ...(options.resourceLoaderOptions ?? {}), extensionFactories: [...builtinExtensionFactories, ...userExtensionFactories], @@ -216,6 +228,7 @@ export async function createAgentSessionServices( modelRegistry, resourceLoader, mcpManager, + projectSkillTrust, diagnostics, }; } @@ -239,6 +252,7 @@ export async function createAgentSessionFromServices( modelRegistry: options.services.modelRegistry, resourceLoader: options.services.resourceLoader, mcpManager: options.services.mcpManager, + projectSkillTrust: options.services.projectSkillTrust, sessionManager: options.sessionManager, model: options.model, thinkingLevel: options.thinkingLevel, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index eec21e2341..a8c9034a27 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -34,6 +34,7 @@ import { resetApiProviders, supportsFastMode, } from "@earendil-works/pi-ai"; +import { getAgentDir } from "../config.js"; import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; @@ -194,6 +195,17 @@ import { RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE, } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; +import { + applyProjectSkillTrust, + createProjectSkillTrustStore, + describeProjectSkillTrust, + formatProjectSkillTrustPrompt, + PROJECT_SKILL_TRUST_CHOICES, + PROJECT_SKILL_TRUST_COMMAND, + type ProjectSkillTrustChoice, + type ProjectSkillTrustStatus, + type ProjectSkillTrustStore, +} from "./project-skill-trust.js"; import { throwIfPromptAdmissionCancelled } from "./prompt-admission.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import { @@ -489,6 +501,12 @@ export interface AgentSessionConfig { subagentRuntimeHost?: SubagentRuntimeHost; autonomous?: AgentAutonomousConfig; prewarmIpythonKernel?: boolean; + /** + * Persisted per-project trust decisions for project Python skills. Defaults to + * the file store in agentDir. Untrusted project Python skills are exposed as + * markdown-only skills and never installed into or imported by the kernel. + */ + projectSkillTrust?: ProjectSkillTrustStore; autoRefineReviewer?: AutoRefineReviewer; /** * When true, auto-refine runs synchronously between turns at the @@ -1242,6 +1260,10 @@ export class AgentSession { /** True once the runtime has been built once; later builds are in-process rebuilds (/reload). */ private _ipythonRuntimeBuilt = false; private readonly _prewarmIpythonKernel: boolean; + private readonly _projectSkillTrust: ProjectSkillTrustStore; + /** Set when the user answered "not now": stay quiet for the rest of this session. */ + private _projectSkillTrustDeferred = false; + private _projectSkillTrustPromptAbort?: AbortController; private _rlmDepth: number; private readonly _configuredRlmMaxDepth: number | undefined; private _rlmMaxDepth: number; @@ -1355,6 +1377,8 @@ export class AgentSession { this._rlmMaxDepth = resolvedRlmMaxDepth.maxDepth; this._rlmMaxDepthSource = resolvedRlmMaxDepth.source; this._prewarmIpythonKernel = (config.prewarmIpythonKernel ?? false) && this._rlmDepth === 0; + this._projectSkillTrust = + config.projectSkillTrust ?? createProjectSkillTrustStore(config.agentDir ?? getAgentDir()); this._autoRefineReviewer = config.autoRefineReviewer; this._serializedRefine = config.serializedRefine ?? false; this._rlmSessionDir = config.rlmSessionDir; @@ -4233,6 +4257,7 @@ export class AgentSession { return; } this._disposed = true; + this._projectSkillTrustPromptAbort?.abort(); for (const run of this._unsettledRlmChildRuns) run.suppressTerminalNotice = true; for (const controller of this._rlmQuiescenceWaitAborts) controller.abort(); this._sessionActionCommitDisposeAbortController.abort(); @@ -9112,6 +9137,97 @@ export class AgentSession { this._applyExtensionBindings(this._extensionRunner); await this._extensionRunner.emit(this._sessionStartEvent); await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup"); + this.promptProjectSkillTrust(); + } + + /** + * Ask for a trust decision on this project's Python skills when one is still + * needed and a UI is bound. Not awaited: the selector waits on the user while + * the client finishes starting up, and the kernel keeps prewarming without the + * project skills. Safe to call again, e.g. when a UI client attaches to a + * daemon session whose bind-time prompt had nobody to answer it. + */ + promptProjectSkillTrust(): void { + void this._promptProjectSkillTrust(); + } + + /** Trust state of this project's Python skills and the skills it applies to. */ + getProjectSkillTrust(): ProjectSkillTrustStatus { + return describeProjectSkillTrust( + this._resourceLoader.getSkills().skills, + this._projectSkillTrust.getDecision(this._cwd), + ); + } + + /** + * Persist a trust decision for this project's Python skills and rebuild the + * runtime so the kernel picks up (or drops) the project packages. + */ + setProjectSkillTrust(decision: ProjectSkillTrustChoice): void { + this._projectSkillTrust.setDecision(this._cwd, decision); + this._rebuildRuntimeForSkillChange(); + } + + private _rebuildRuntimeForSkillChange(): void { + this._buildRuntime({ + activeToolNames: this.getActiveToolNames(), + includeAllExtensionTools: true, + }); + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + + /** + * Only prompts when a UI is bound. Headless modes (print, JSON, ACP, subagents) + * never prompt, so an undecided project stays untrusted there. + */ + private async _promptProjectSkillTrust(): Promise { + if (this._projectSkillTrustPromptAbort || this._projectSkillTrustDeferred) return; + if (this._rlmDepth > 0 || this._disposed || this._disposing) return; + const ui = this._extensionUIContext; + if (!ui || !this._extensionRunner.hasUI()) return; + const status = this.getProjectSkillTrust(); + if (status.skills.length === 0 || status.decision !== "undecided") return; + const abort = new AbortController(); + this._projectSkillTrustPromptAbort = abort; + const names = status.skills.map((skill) => skill.name); + let choice: string | undefined; + try { + choice = await ui.select( + formatProjectSkillTrustPrompt(names), + [PROJECT_SKILL_TRUST_CHOICES.trust, PROJECT_SKILL_TRUST_CHOICES.notNow, PROJECT_SKILL_TRUST_CHOICES.never], + { signal: abort.signal }, + ); + } catch { + return; + } finally { + if (this._projectSkillTrustPromptAbort === abort) this._projectSkillTrustPromptAbort = undefined; + } + if (abort.signal.aborted || this._disposed || this._disposing) return; + // Dismissed, or no client could answer (a daemon session bound before its + // client attached): stay undecided and ask again when a UI shows up. + if (choice === undefined) return; + if (choice === PROJECT_SKILL_TRUST_CHOICES.trust) { + this.setProjectSkillTrust("trusted"); + ui.notify( + `Trusted project Python skills: ${names.join(", ")}. Installing them into the project kernel.`, + "info", + ); + return; + } + if (choice === PROJECT_SKILL_TRUST_CHOICES.never) { + this._projectSkillTrust.setDecision(this._cwd, "denied"); + ui.notify( + `Project Python skills stay disabled for this project: ${names.join(", ")}. Run /${PROJECT_SKILL_TRUST_COMMAND} on to enable them.`, + "info", + ); + return; + } + this._projectSkillTrustDeferred = true; + ui.notify( + `Project Python skills are disabled this session: ${names.join(", ")}. Run /${PROJECT_SKILL_TRUST_COMMAND} on to enable them.`, + "warning", + ); } private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise { @@ -9526,7 +9642,9 @@ export class AgentSession { * and compact skills are withheld when disabled for this session. */ private _modelVisibleSkills(): Skill[] { - let skills = this._resourceLoader.getSkills().skills; + let skills = applyProjectSkillTrust(this._resourceLoader.getSkills().skills, () => + this._projectSkillTrust.getDecision(this._cwd), + ); if (!this._includeGoals) { skills = skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); } diff --git a/packages/coding-agent/src/core/extensions/builtin/project-skill-trust.ts b/packages/coding-agent/src/core/extensions/builtin/project-skill-trust.ts new file mode 100644 index 0000000000..476722f364 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/project-skill-trust.ts @@ -0,0 +1,82 @@ +/** + * Built-in `/trust-project-skills` command. + * + * Project Python skills (code shipped inside the opened repository) are only + * installed into the kernel after an explicit, persisted trust decision. The + * startup selector asks once; this command changes the decision later and + * reloads so the kernel picks up (or drops) the project packages. + */ + +import { + getProjectPythonSkills, + PROJECT_SKILL_TRUST_COMMAND, + type ProjectSkillTrustStore, +} from "../../project-skill-trust.js"; +import type { Skill } from "../../skills.js"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "../types.js"; + +export interface ProjectSkillTrustExtensionOptions { + store: ProjectSkillTrustStore; + /** Skills as discovered by the resource loader (before the session applies trust). */ + getSkills: () => readonly Skill[]; +} + +const SUBCOMMANDS = ["status", "on", "off", "reset"] as const; +type Subcommand = (typeof SUBCOMMANDS)[number]; + +function parseSubcommand(args: string): Subcommand | undefined { + const trimmed = args.trim().toLowerCase(); + if (trimmed === "") return "status"; + return SUBCOMMANDS.find((candidate) => candidate === trimmed); +} + +function formatSkillList(skills: readonly Skill[]): string { + const names = getProjectPythonSkills(skills).map((skill) => skill.name); + return names.length > 0 ? names.join(", ") : "none discovered"; +} + +export function createProjectSkillTrustExtension(options: ProjectSkillTrustExtensionOptions): ExtensionFactory { + return (pi: ExtensionAPI) => { + pi.registerCommand(PROJECT_SKILL_TRUST_COMMAND, { + description: "Trust (on), deny (off), reset, or show the status of this project's Python skills", + getArgumentCompletions: (prefix) => + SUBCOMMANDS.filter((candidate) => candidate.startsWith(prefix.trim().toLowerCase())).map((value) => ({ + value, + label: value, + })), + handler: async (args: string, ctx: ExtensionCommandContext) => { + const subcommand = parseSubcommand(args); + if (!subcommand) { + ctx.ui.notify(`Usage: /${PROJECT_SKILL_TRUST_COMMAND} [status|on|off|reset]`, "error"); + return; + } + const skills = formatSkillList(options.getSkills()); + if (subcommand === "status") { + ctx.ui.notify( + `Project Python skills are ${options.store.getDecision(ctx.cwd)} for ${ctx.cwd} (${skills}).`, + "info", + ); + return; + } + if (subcommand === "reset") { + options.store.clearDecision(ctx.cwd); + ctx.ui.notify( + `Cleared the project Python skill decision for ${ctx.cwd}; the next session start asks again.`, + "info", + ); + return; + } + const decision = subcommand === "on" ? "trusted" : "denied"; + options.store.setDecision(ctx.cwd, decision); + ctx.ui.notify( + decision === "trusted" + ? `Trusted project Python skills for ${ctx.cwd} (${skills}). Reloading to install them.` + : `Project Python skills disabled for ${ctx.cwd} (${skills}). Reloading to remove them.`, + "info", + ); + await ctx.waitForIdle(); + await ctx.reload(); + }, + }); + }; +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index ba96318205..8346abd3be 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -9,6 +9,7 @@ export { hasFileBasedHerdrIntegration, herdrAgentStateExtension, } from "./builtin/herdr-agent-state.js"; +export { createProjectSkillTrustExtension } from "./builtin/project-skill-trust.js"; export { createExtensionRuntime, discoverAndLoadExtensions, diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 5ab93cc64e..fd73dbef92 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { constants, existsSync, readdirSync, readFileSync } from "node:fs"; +import { constants, existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -101,6 +101,12 @@ export type KernelBootstrapProgressHandler = (message: string) => void; export interface EnsureKernelPythonOptions { pythonSkills?: readonly KernelPythonSkill[]; onProgress?: KernelBootstrapProgressHandler; + /** + * Project the kernel runs in. When any project-scoped Python skill is requested + * the kernel gets a per-project venv keyed by this path, so project code never + * lands in the shared user venv. Defaults to process.cwd(). + */ + projectDir?: string; } interface BootstrapPythonSkill { @@ -357,12 +363,13 @@ function formatPythonSkillInstallArgs(skill: BootstrapPythonSkill): string[] { return ["--editable", skill.packagePath]; } -function ensureKernelPythonKey(pythonSkills: readonly BootstrapPythonSkill[]): string { +function ensureKernelPythonKey(pythonSkills: readonly BootstrapPythonSkill[], projectDir: string | undefined): string { return [ process.env.PRIME_AGENT_KERNEL_PYTHON ?? "", process.env.PRIME_AGENT_KERNEL_VENV ?? "", process.env.HOME ?? "", process.env.XDG_DATA_HOME ?? "", + projectDir ?? "", JSON.stringify(pythonSkills), ].join("\0"); } @@ -373,6 +380,40 @@ export function getKernelVenvDir(): string { return path.join(os.homedir(), ".prime", "agent", "kernel-venv"); } +function hasProjectScopedSkills(pythonSkills: readonly KernelPythonSkill[] | undefined): boolean { + return (pythonSkills ?? []).some((skill) => skill.scope === "project"); +} + +function canonicalProjectDir(projectDir: string): string { + const resolved = path.resolve(projectDir); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +function projectKernelVenvDirFor(baseVenv: string, projectDir: string): string { + const canonical = canonicalProjectDir(projectDir); + const slug = + path + .basename(canonical) + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) || "project"; + const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 12); + return path.join(path.dirname(baseVenv), `${path.basename(baseVenv)}-projects`, `${slug}-${digest}`); +} + +/** + * Kernel venv used when a project's own Python skills are installed. Sibling of the + * shared venv (`-projects/-`), one per canonical project path, so + * project packages and their dependencies never persist into the shared venv. + */ +export function getProjectKernelVenvDir(projectDir: string, baseVenv: string = getKernelVenvDir()): string { + return projectKernelVenvDirFor(baseVenv, projectDir); +} + function getXdgKernelVenvDir(): string { const dataHome = process.env.XDG_DATA_HOME ? path.resolve(expandHome(process.env.XDG_DATA_HOME)) @@ -884,6 +925,7 @@ function formatBootstrapFailure(error: unknown): Error { async function ensureKernelPythonUncached( options: EnsureKernelPythonOptions, pythonSkills: readonly BootstrapPythonSkill[], + projectDir: string | undefined, ): Promise { const override = process.env.PRIME_AGENT_KERNEL_PYTHON; if (override) { @@ -918,7 +960,8 @@ async function ensureKernelPythonUncached( throw new Error(`PRIME_AGENT_KERNEL_PYTHON points to a Python missing ${missing.join(" and ")}: ${python}`); } - const venv = await resolveWritableKernelVenvDir(); + const sharedVenv = await resolveWritableKernelVenvDir(); + const venv = projectDir === undefined ? sharedVenv : projectKernelVenvDirFor(sharedVenv, projectDir); const python = kernelVenvPython(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; @@ -951,10 +994,14 @@ async function ensureKernelPythonUncached( export function ensureKernelPython(options: EnsureKernelPythonOptions = {}): Promise { const pythonSkills = normalizePythonSkills(options.pythonSkills); - const key = ensureKernelPythonKey(pythonSkills); + // Only sessions that actually install project code leave the shared venv. + const projectDir = hasProjectScopedSkills(options.pythonSkills) + ? canonicalProjectDir(options.projectDir ?? process.cwd()) + : undefined; + const key = ensureKernelPythonKey(pythonSkills, projectDir); if (inFlightEnsureKernelPython?.key === key) return inFlightEnsureKernelPython.promise; - const promise = ensureKernelPythonUncached(options, pythonSkills).finally(() => { + const promise = ensureKernelPythonUncached(options, pythonSkills, projectDir).finally(() => { if (inFlightEnsureKernelPython?.promise === promise) inFlightEnsureKernelPython = null; }); inFlightEnsureKernelPython = { key, promise }; diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 5023e48729..c62592d02c 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -298,6 +298,7 @@ export class ReplKernelManager { (await ensureKernelPython({ pythonSkills: this.options.pythonSkills, onProgress: startOptions.onBootstrapProgress, + projectDir: this.options.cwd, })); if (this.startStale(generation)) throw new Error("Kernel start superseded"); this.options.python = python; diff --git a/packages/coding-agent/src/core/project-skill-trust.ts b/packages/coding-agent/src/core/project-skill-trust.ts new file mode 100644 index 0000000000..f4f1ac432e --- /dev/null +++ b/packages/coding-agent/src/core/project-skill-trust.ts @@ -0,0 +1,205 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { writeFileAtomicSync } from "../utils/atomic-file.js"; +import { canonicalizePath } from "../utils/paths.js"; +import type { MarkdownSkill, PythonSkill, Skill } from "./skills.js"; + +/** + * Trust decisions for project-scoped Python skills. + * + * A project Python skill (`/.prime/agent/skills//pyproject.toml`, or a + * Python skill added by the project's settings) is code from the repository being + * opened. Installing it runs its build backend and importing it runs its module + * code inside the kernel, so neither happens until the user trusts the project. + * Decisions are persisted per canonical project path in the agent dir; a project + * without a decision is treated as untrusted. + */ + +export type ProjectSkillTrustDecision = "trusted" | "denied" | "undecided"; +export type ProjectSkillTrustChoice = Exclude; + +export const PROJECT_SKILL_TRUST_FILE = "project-skill-trust.json"; +export const PROJECT_SKILL_TRUST_COMMAND = "trust-project-skills"; + +interface ProjectSkillTrustRecord { + decision: ProjectSkillTrustChoice; + decidedAt: string; +} + +interface ProjectSkillTrustFile { + version: 1; + projects: Record; +} + +export interface ProjectSkillTrustStore { + /** Where decisions are persisted; undefined for in-memory stores. */ + readonly path?: string; + getDecision(projectDir: string): ProjectSkillTrustDecision; + setDecision(projectDir: string, decision: ProjectSkillTrustChoice): void; + clearDecision(projectDir: string): void; +} + +export function projectSkillTrustKey(projectDir: string): string { + return canonicalizePath(resolve(projectDir)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function emptyTrustFile(): ProjectSkillTrustFile { + return { version: 1, projects: {} }; +} + +function parseTrustFile(raw: string): ProjectSkillTrustFile { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.projects)) { + return emptyTrustFile(); + } + const projects: Record = {}; + for (const [key, value] of Object.entries(parsed.projects)) { + if (!isRecord(value)) continue; + if (value.decision !== "trusted" && value.decision !== "denied") continue; + projects[key] = { + decision: value.decision, + decidedAt: typeof value.decidedAt === "string" ? value.decidedAt : new Date(0).toISOString(), + }; + } + return { version: 1, projects }; +} + +class FileProjectSkillTrustStore implements ProjectSkillTrustStore { + constructor(readonly path: string) {} + + private read(): ProjectSkillTrustFile { + if (!existsSync(this.path)) return emptyTrustFile(); + try { + return parseTrustFile(readFileSync(this.path, "utf8")); + } catch { + // An unreadable or corrupt store must fail closed: nothing is trusted. + return emptyTrustFile(); + } + } + + private write(file: ProjectSkillTrustFile): void { + mkdirSync(dirname(this.path), { recursive: true }); + writeFileAtomicSync(this.path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + } + + getDecision(projectDir: string): ProjectSkillTrustDecision { + return this.read().projects[projectSkillTrustKey(projectDir)]?.decision ?? "undecided"; + } + + setDecision(projectDir: string, decision: ProjectSkillTrustChoice): void { + const file = this.read(); + file.projects[projectSkillTrustKey(projectDir)] = { decision, decidedAt: new Date().toISOString() }; + this.write(file); + } + + clearDecision(projectDir: string): void { + const file = this.read(); + const key = projectSkillTrustKey(projectDir); + if (!(key in file.projects)) return; + delete file.projects[key]; + this.write(file); + } +} + +class InMemoryProjectSkillTrustStore implements ProjectSkillTrustStore { + readonly path = undefined; + private readonly decisions = new Map(); + + getDecision(projectDir: string): ProjectSkillTrustDecision { + return this.decisions.get(projectSkillTrustKey(projectDir)) ?? "undecided"; + } + + setDecision(projectDir: string, decision: ProjectSkillTrustChoice): void { + this.decisions.set(projectSkillTrustKey(projectDir), decision); + } + + clearDecision(projectDir: string): void { + this.decisions.delete(projectSkillTrustKey(projectDir)); + } +} + +export function createProjectSkillTrustStore(agentDir: string): ProjectSkillTrustStore { + return new FileProjectSkillTrustStore(join(agentDir, PROJECT_SKILL_TRUST_FILE)); +} + +export function createInMemoryProjectSkillTrustStore(): ProjectSkillTrustStore { + return new InMemoryProjectSkillTrustStore(); +} + +/** Python skills whose code comes from the opened project rather than the user's own config. */ +export function isProjectPythonSkill(skill: Skill): skill is PythonSkill { + return skill.kind === "python" && skill.sourceInfo.scope === "project"; +} + +export function getProjectPythonSkills(skills: readonly Skill[]): PythonSkill[] { + return skills.filter(isProjectPythonSkill); +} + +/** + * Keep the SKILL.md of an untrusted project Python skill readable but strip the + * Python half, so it neither reaches the kernel bootstrap nor advertises a + * `python_import` the kernel does not provide. + */ +export function downgradeProjectPythonSkill(skill: PythonSkill): MarkdownSkill { + return { + kind: "markdown", + name: skill.name, + description: skill.description, + filePath: skill.filePath, + baseDir: skill.baseDir, + sourceInfo: skill.sourceInfo, + disableModelInvocation: skill.disableModelInvocation, + }; +} + +/** + * Apply the project's trust decision to a skill list. The decision is resolved + * lazily so sessions without project Python skills never touch the store. + */ +export function applyProjectSkillTrust( + skills: readonly Skill[], + getDecision: () => ProjectSkillTrustDecision, +): Skill[] { + if (!skills.some(isProjectPythonSkill)) return [...skills]; + if (getDecision() === "trusted") return [...skills]; + return skills.map((skill) => (isProjectPythonSkill(skill) ? downgradeProjectPythonSkill(skill) : skill)); +} + +export interface ProjectSkillTrustStatus { + decision: ProjectSkillTrustDecision; + /** Project Python skills discovered for the current project (trusted or not). */ + skills: Array<{ name: string; importName: string; packagePath: string }>; +} + +export function describeProjectSkillTrust( + skills: readonly Skill[], + decision: ProjectSkillTrustDecision, +): ProjectSkillTrustStatus { + return { + decision, + skills: getProjectPythonSkills(skills).map((skill) => ({ + name: skill.name, + importName: skill.python.importName, + packagePath: skill.python.packagePath, + })), + }; +} + +export const PROJECT_SKILL_TRUST_CHOICES = { + trust: "Trust and install", + notNow: "Not now (ask again next time)", + never: "Never for this project", +} as const; + +export function formatProjectSkillTrustPrompt(skillNames: readonly string[]): string { + const list = skillNames.join(", "); + return [ + `This project provides Python skills: ${list}`, + "Installing them builds packages from this repository and imports their code in the Python kernel.", + `Trust this project's Python skills? Change later with /${PROJECT_SKILL_TRUST_COMMAND}.`, + ].join("\n"); +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 723209fef0..07bc1eed3b 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -13,6 +13,7 @@ import { McpManager } from "./mcp/mcp-manager.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import { findInitialModel } from "./model-resolver.js"; +import { createProjectSkillTrustStore, type ProjectSkillTrustStore } from "./project-skill-trust.js"; import type { ResourceLoader } from "./resource-loader.js"; import { DefaultResourceLoader } from "./resource-loader.js"; import { getDefaultSessionDir, SessionManager } from "./session-manager.js"; @@ -63,6 +64,9 @@ export interface CreateAgentSessionOptions extends AgentSessionCreationOptions { /** MCP integration manager. When omitted, MCP host handlers are not wired. */ mcpManager?: McpManager; + /** Trust store for project Python skills. Default: file store in agentDir. */ + projectSkillTrust?: ProjectSkillTrustStore; + /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; @@ -372,6 +376,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} subagentRuntimeHost: options.subagentRuntimeHost, sessionStartEvent: options.sessionStartEvent, prewarmIpythonKernel: options.prewarmIpythonKernel, + projectSkillTrust: options.projectSkillTrust ?? createProjectSkillTrustStore(agentDir), autonomous: options.autonomous, serializedRefine: options.serializedRefine, initialGoal: options.initialGoal, diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index d8b2bd28cf..15a5fd1bb2 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -7,7 +7,7 @@ import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import { canonicalizePath } from "../utils/paths.js"; import type { ResourceDiagnostic } from "./diagnostics.js"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; +import { createSyntheticSourceInfo, type SourceInfo, type SourceScope } from "./source-info.js"; const log = getLogger("coding-agent.skills"); @@ -108,6 +108,8 @@ export type Skill = MarkdownSkill | PythonSkill; export interface PythonSkillRuntimeInfo extends SkillPythonMetadata { name: string; + /** Where the skill came from; project-scoped skills install into a per-project kernel venv. */ + scope?: SourceScope; } export interface LoadSkillsResult { @@ -261,6 +263,7 @@ export function getPythonSkillRuntimeInfo(skills: readonly Skill[]): PythonSkill importName: skill.python.importName, packagePath: skill.python.packagePath, pyprojectPath: skill.python.pyprojectPath, + scope: skill.sourceInfo.scope, })); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1eaa7a0927..4d3191d795 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -151,6 +151,19 @@ export type { ResolvedResource, } from "./core/package-manager.js"; export { DefaultPackageManager } from "./core/package-manager.js"; +export { + applyProjectSkillTrust, + createInMemoryProjectSkillTrustStore, + createProjectSkillTrustStore, + getProjectPythonSkills, + isProjectPythonSkill, + PROJECT_SKILL_TRUST_COMMAND, + PROJECT_SKILL_TRUST_FILE, + type ProjectSkillTrustChoice, + type ProjectSkillTrustDecision, + type ProjectSkillTrustStatus, + type ProjectSkillTrustStore, +} from "./core/project-skill-trust.js"; export type { HarnessState, RefinementEdit, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 566761fd6f..5ee3da6e0c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1740,6 +1740,19 @@ export class AgentDaemon { } } + /** + * Sessions bind their extensions before any client attaches, so the project + * skill trust selector fired at bind time had nobody to answer it. Re-ask once + * a client that renders extension UI is attached; the response is written first. + */ + private promptProjectSkillTrustWhenUiAttached(client: DaemonSocketClient, state: ActiveSessionState): void { + if (!daemonClientSupportsExtensionUi(client, state.activeSessionId)) return; + setImmediate(() => { + if (this.sessions.get(state.activeSessionId) !== state) return; + state.runtime.session.promptProjectSkillTrust(); + }); + } + private async createRuntime( command: Extract, runtimeOpenGuard?: RuntimeOpenGuard, @@ -3955,6 +3968,7 @@ export class AgentDaemon { ); state.clients.add(client); client.attachedActiveSessionIds.add(state.activeSessionId); + this.promptProjectSkillTrustWhenUiAttached(client, state); this.write(client, success(command.id, "attach", summaryForActiveSession(state))); return; } @@ -4206,6 +4220,7 @@ export class AgentDaemon { } state.clients.add(client); client.attachedActiveSessionIds.add(state.activeSessionId); + this.promptProjectSkillTrustWhenUiAttached(client, state); // Carrier-less mutation: a direct viewer changes directAttachedClients with no session event. if (client.authenticationRole === "session_client") this.scheduleRosterFlush(); if (deferClientEnv && clientEnv) { diff --git a/packages/coding-agent/test/kernel-bootstrap.test.ts b/packages/coding-agent/test/kernel-bootstrap.test.ts index f1cf37a88f..2f0a96490a 100644 --- a/packages/coding-agent/test/kernel-bootstrap.test.ts +++ b/packages/coding-agent/test/kernel-bootstrap.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -8,6 +8,7 @@ import { DEFAULT_RLM_EXTRA_UV_ARGS, ensureKernelPython, getKernelVenvDir, + getProjectKernelVenvDir, type KernelPythonSkill, kernelVenvPython, resolveRuntimeIdentity, @@ -243,6 +244,64 @@ describe("kernel bootstrap", () => { ]); }); + it("installs project-scoped Python skills into a per-project venv, never the shared venv", async () => { + const logPath = installFakeUv(); + const venv = join(tempDir, "kernel-venv"); + const projectDir = join(tempDir, "project"); + mkdirSync(projectDir, { recursive: true }); + const projectSkill: KernelPythonSkill = { ...createPythonSkill("marker-skill"), scope: "project" }; + const userSkill: KernelPythonSkill = { ...createPythonSkill("web-search"), scope: "user" }; + process.env.PRIME_AGENT_KERNEL_VENV = venv; + + const projectVenv = getProjectKernelVenvDir(projectDir, venv); + expect(projectVenv.startsWith(`${venv}-projects/`)).toBe(true); + expect(projectVenv).not.toBe(venv); + await expect(ensureKernelPython({ pythonSkills: [userSkill, projectSkill], projectDir })).resolves.toBe( + join(projectVenv, "bin", "python"), + ); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain(`venv ${projectVenv} --python 3.11 --seed`); + expect(log).not.toContain(`venv ${venv} --python 3.11 --seed`); + expect(log).toContain(`--python ${join(projectVenv, "bin", "python")} --editable ${projectSkill.packagePath}`); + expect(log).not.toContain(`--python ${join(venv, "bin", "python")}`); + expect(existsSync(venv)).toBe(false); + const version = JSON.parse(readFileSync(join(projectVenv, ".bootstrap-version"), "utf8")); + expect(version.pythonSkills.map((skill: { importName: string }) => skill.importName)).toEqual([ + "marker_skill", + "web_search", + ]); + }); + + it("keeps user-scoped Python skills in the shared venv", async () => { + const logPath = installFakeUv(); + const venv = join(tempDir, "kernel-venv"); + const projectDir = join(tempDir, "project"); + mkdirSync(projectDir, { recursive: true }); + const userSkill: KernelPythonSkill = { ...createPythonSkill("web-search"), scope: "user" }; + process.env.PRIME_AGENT_KERNEL_VENV = venv; + + await expect(ensureKernelPython({ pythonSkills: [userSkill], projectDir })).resolves.toBe( + join(venv, "bin", "python"), + ); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain(`venv ${venv} --python 3.11 --seed`); + expect(existsSync(`${venv}-projects`)).toBe(false); + }); + + it("derives one stable project venv per canonical project path", () => { + const venv = join(tempDir, "kernel-venv"); + const projectA = join(tempDir, "project-a"); + const projectB = join(tempDir, "project-b"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + + expect(getProjectKernelVenvDir(projectA, venv)).toBe(getProjectKernelVenvDir(join(projectA, "."), venv)); + expect(getProjectKernelVenvDir(projectA, venv)).not.toBe(getProjectKernelVenvDir(projectB, venv)); + expect(getProjectKernelVenvDir(projectA, venv)).toMatch(/kernel-venv-projects\/project-a-[0-9a-f]{12}$/); + }); + it("installs sibling Python skill dependencies with dependent editable packages", async () => { const logPath = installFakeUv(); const venv = join(tempDir, "kernel-venv"); diff --git a/packages/coding-agent/test/project-skill-trust.test.ts b/packages/coding-agent/test/project-skill-trust.test.ts new file mode 100644 index 0000000000..eaa940a022 --- /dev/null +++ b/packages/coding-agent/test/project-skill-trust.test.ts @@ -0,0 +1,223 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + applyProjectSkillTrust, + createInMemoryProjectSkillTrustStore, + createProjectSkillTrustStore, + describeProjectSkillTrust, + getProjectPythonSkills, + isProjectPythonSkill, + PROJECT_SKILL_TRUST_FILE, + projectSkillTrustKey, +} from "../src/core/project-skill-trust.js"; +import type { PythonSkill, Skill } from "../src/core/skills.js"; +import { createSyntheticSourceInfo, type SourceScope } from "../src/core/source-info.js"; + +let tempDir = ""; + +function pythonSkill(name: string, scope: SourceScope): PythonSkill { + const skillDir = join(tempDir, scope, name); + const filePath = join(skillDir, "SKILL.md"); + return { + kind: "python", + name, + description: `${name} skill`, + filePath, + baseDir: skillDir, + sourceInfo: createSyntheticSourceInfo(filePath, { source: "local", scope, baseDir: skillDir }), + disableModelInvocation: false, + python: { + importName: name.replaceAll("-", "_"), + packagePath: skillDir, + pyprojectPath: join(skillDir, "pyproject.toml"), + }, + }; +} + +function markdownSkill(name: string, scope: SourceScope): Skill { + const skillDir = join(tempDir, scope, name); + const filePath = join(skillDir, "SKILL.md"); + return { + kind: "markdown", + name, + description: `${name} skill`, + filePath, + baseDir: skillDir, + sourceInfo: createSyntheticSourceInfo(filePath, { source: "local", scope, baseDir: skillDir }), + disableModelInvocation: false, + }; +} + +describe("project skill trust store", () => { + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "prime-agent-project-skill-trust-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("treats projects without a decision as undecided and persists decisions per project", () => { + const agentDir = join(tempDir, "agent"); + const projectA = join(tempDir, "a"); + const projectB = join(tempDir, "b"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + const store = createProjectSkillTrustStore(agentDir); + + expect(store.getDecision(projectA)).toBe("undecided"); + expect(existsSync(join(agentDir, PROJECT_SKILL_TRUST_FILE))).toBe(false); + + store.setDecision(projectA, "trusted"); + store.setDecision(projectB, "denied"); + + expect(store.getDecision(projectA)).toBe("trusted"); + expect(store.getDecision(projectB)).toBe("denied"); + expect(createProjectSkillTrustStore(agentDir).getDecision(projectA)).toBe("trusted"); + const file = JSON.parse(readFileSync(join(agentDir, PROJECT_SKILL_TRUST_FILE), "utf8")); + expect(file.version).toBe(1); + expect(file.projects[projectSkillTrustKey(projectA)]).toMatchObject({ decision: "trusted" }); + if (process.platform !== "win32") { + expect(statSync(join(agentDir, PROJECT_SKILL_TRUST_FILE)).mode & 0o777).toBe(0o600); + } + + store.clearDecision(projectA); + expect(store.getDecision(projectA)).toBe("undecided"); + expect(store.getDecision(projectB)).toBe("denied"); + }); + + it("keys decisions by the canonical project path", () => { + const agentDir = join(tempDir, "agent"); + const project = join(tempDir, "real-project"); + const link = join(tempDir, "linked-project"); + mkdirSync(project, { recursive: true }); + symlinkSync(project, link, "dir"); + const store = createProjectSkillTrustStore(agentDir); + + store.setDecision(link, "trusted"); + + expect(store.getDecision(project)).toBe("trusted"); + expect(store.getDecision(`${project}/`)).toBe("trusted"); + expect(store.getDecision(join(tempDir, "other"))).toBe("undecided"); + }); + + it("fails closed on a corrupt or unreadable store", () => { + const agentDir = join(tempDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + const path = join(agentDir, PROJECT_SKILL_TRUST_FILE); + writeFileSync(path, "{ not json"); + const store = createProjectSkillTrustStore(agentDir); + expect(store.getDecision(tempDir)).toBe("undecided"); + + writeFileSync( + path, + JSON.stringify({ version: 1, projects: { [projectSkillTrustKey(tempDir)]: { decision: "yes" } } }), + ); + expect(store.getDecision(tempDir)).toBe("undecided"); + + if (process.platform !== "win32" && process.getuid?.() !== 0) { + writeFileSync( + path, + JSON.stringify({ version: 1, projects: { [projectSkillTrustKey(tempDir)]: { decision: "trusted" } } }), + ); + chmodSync(path, 0o000); + try { + expect(store.getDecision(tempDir)).toBe("undecided"); + } finally { + chmodSync(path, 0o600); + } + } + }); + + it("keeps in-memory decisions out of the filesystem", () => { + const store = createInMemoryProjectSkillTrustStore(); + store.setDecision(tempDir, "trusted"); + expect(store.getDecision(tempDir)).toBe("trusted"); + expect(store.path).toBeUndefined(); + expect(existsSync(join(tempDir, PROJECT_SKILL_TRUST_FILE))).toBe(false); + }); +}); + +describe("applyProjectSkillTrust", () => { + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "prime-agent-project-skill-trust-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("only classifies project-scoped Python skills as project Python skills", () => { + const projectPython = pythonSkill("marker-skill", "project"); + const userPython = pythonSkill("web-search", "user"); + const projectMarkdown = markdownSkill("notes", "project"); + + expect(isProjectPythonSkill(projectPython)).toBe(true); + expect(isProjectPythonSkill(userPython)).toBe(false); + expect(isProjectPythonSkill(projectMarkdown)).toBe(false); + expect(getProjectPythonSkills([projectPython, userPython, projectMarkdown])).toEqual([projectPython]); + }); + + it("downgrades project Python skills to markdown unless the project is trusted", () => { + const projectPython = pythonSkill("marker-skill", "project"); + const userPython = pythonSkill("web-search", "user"); + const projectMarkdown = markdownSkill("notes", "project"); + const skills = [projectPython, userPython, projectMarkdown]; + + for (const decision of ["undecided", "denied"] as const) { + const applied = applyProjectSkillTrust(skills, () => decision); + expect(applied.map((skill) => skill.kind)).toEqual(["markdown", "python", "markdown"]); + expect(applied[0]).toMatchObject({ + kind: "markdown", + name: "marker-skill", + filePath: projectPython.filePath, + sourceInfo: projectPython.sourceInfo, + }); + expect((applied[0] as { python?: unknown }).python).toBeUndefined(); + expect(applied[1]).toBe(userPython); + expect(applied[2]).toBe(projectMarkdown); + } + + const trusted = applyProjectSkillTrust(skills, () => "trusted"); + expect(trusted).toEqual(skills); + }); + + it("does not consult the store when no project Python skill is present", () => { + let consulted = 0; + const applied = applyProjectSkillTrust( + [pythonSkill("web-search", "user"), markdownSkill("notes", "project")], + () => { + consulted += 1; + return "denied"; + }, + ); + expect(consulted).toBe(0); + expect(applied.map((skill) => skill.kind)).toEqual(["python", "markdown"]); + }); + + it("describes the project Python skills a decision applies to", () => { + const projectPython = pythonSkill("marker-skill", "project"); + expect(describeProjectSkillTrust([projectPython, pythonSkill("web-search", "user")], "denied")).toEqual({ + decision: "denied", + skills: [ + { + name: "marker-skill", + importName: "marker_skill", + packagePath: projectPython.python.packagePath, + }, + ], + }); + }); +}); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index f16f6e4496..5269cce17e 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -286,6 +286,7 @@ describe("skills", () => { importName: "python_skill", packagePath: skillDir, pyprojectPath: join(skillDir, "pyproject.toml"), + scope: "temporary", }, ]); expect(diagnostics).toHaveLength(0); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 98cd048580..0d2f34bd1d 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -17,6 +17,10 @@ import type { AgentAutonomousConfig } from "../../src/core/autonomous.js"; import type { ExtensionRunner } from "../../src/core/extensions/index.js"; import { convertToLlm, HARNESS_DIGEST_CUSTOM_TYPE } from "../../src/core/messages.js"; import { ModelRegistry } from "../../src/core/model-registry.js"; +import { + createInMemoryProjectSkillTrustStore, + type ProjectSkillTrustStore, +} from "../../src/core/project-skill-trust.js"; import type { SubagentRuntimeHost } from "../../src/core/rlm-runtime.js"; import { SessionManager } from "../../src/core/session-manager.js"; import type { Settings } from "../../src/core/settings-manager.js"; @@ -88,6 +92,8 @@ export interface HarnessOptions { autoRefineReviewer?: AutoRefineReviewer; serializedRefine?: boolean; initialGoal?: { objective: string; tokenBudget?: number }; + /** Trust store for project Python skills. Default: in-memory (never the user's real store). */ + projectSkillTrust?: ProjectSkillTrustStore; } export interface Harness { @@ -217,6 +223,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise skill.importName); +} + +function promptSkillEntry(harness: Harness, name: string): string { + const match = harness.session.systemPrompt.match(new RegExp(`\\s*${name}[\\s\\S]*?`)); + expect(match, `skill ${name} listed in the system prompt`).not.toBeNull(); + return match![0]; +} + +function createUi(select: ExtensionUIContext["select"]): { + ui: ExtensionUIContext; + notifications: Array<{ message: string; type?: string }>; +} { + const notifications: Array<{ message: string; type?: string }> = []; + const ui = { + select, + confirm: async () => false, + input: async () => undefined, + notify: (message: string, type?: string) => { + notifications.push({ message, type }); + }, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + } as unknown as ExtensionUIContext; + return { ui, notifications }; +} + +describe("ENG-5338: project Python skills require an explicit trust decision", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + async function createSkillHarness(options?: { + store?: ProjectSkillTrustStore; + withCommand?: boolean; + }): Promise<{ harness: Harness; store: ProjectSkillTrustStore; skills: PythonSkill[] }> { + const store = options?.store ?? createInMemoryProjectSkillTrustStore(); + // Skills are resolved lazily so the root can be the harness temp dir. + let skills: PythonSkill[] = []; + const extensionsResult = options?.withCommand + ? await createTestExtensionsResult([createProjectSkillTrustExtension({ store, getSkills: () => skills })]) + : undefined; + const resourceLoader = createTestResourceLoader({ extensionsResult }); + resourceLoader.getSkills = () => ({ skills, diagnostics: [] }); + const harness = await createHarness({ resourceLoader, projectSkillTrust: store }); + harnesses.push(harness); + skills = [ + pythonSkill(harness.tempDir, PROJECT_SKILL, "project"), + pythonSkill(harness.tempDir, USER_SKILL, "user"), + ]; + // The harness built its runtime before the skills existed; rebuild the way /reload does. + await harness.session.reload(); + return { harness, store, skills }; + } + + it("keeps untrusted project Python skills out of the kernel and the python_import surface", async () => { + const { harness } = await createSkillHarness(); + + expect(harness.session.getProjectSkillTrust()).toEqual({ + decision: "undecided", + skills: [expect.objectContaining({ name: PROJECT_SKILL, importName: "marker_skill" })], + }); + // The kernel provisioner never sees the project package, so nothing is built or imported. + expect(kernelImportNames(harness)).toEqual(["web_search"]); + // The SKILL.md stays readable as a markdown skill; the user-level skill is untouched. + const projectEntry = promptSkillEntry(harness, PROJECT_SKILL); + expect(projectEntry).toContain("markdown"); + expect(projectEntry).not.toContain("python_import"); + const userEntry = promptSkillEntry(harness, USER_SKILL); + expect(userEntry).toContain("python"); + expect(userEntry).toContain("web_search"); + }); + + it("never prompts and stays denied when no UI is bound (headless modes)", async () => { + const { harness, store } = await createSkillHarness(); + + await harness.session.bindExtensions({}); + await new Promise((resolve) => setImmediate(resolve)); + + expect(store.getDecision(harness.tempDir)).toBe("undecided"); + expect(kernelImportNames(harness)).toEqual(["web_search"]); + }); + + it("installs the project skills only after the user trusts the project in the UI prompt", async () => { + const { harness, store } = await createSkillHarness(); + const select = vi.fn(async (title: string, options: string[]) => { + expect(title).toContain(PROJECT_SKILL); + expect(title).not.toContain(USER_SKILL); + expect(options).toEqual([ + PROJECT_SKILL_TRUST_CHOICES.trust, + PROJECT_SKILL_TRUST_CHOICES.notNow, + PROJECT_SKILL_TRUST_CHOICES.never, + ]); + return PROJECT_SKILL_TRUST_CHOICES.trust; + }); + const { ui, notifications } = createUi(select); + + await harness.session.bindExtensions({ uiContext: ui }); + await vi.waitFor(() => expect(store.getDecision(harness.tempDir)).toBe("trusted")); + + expect(select).toHaveBeenCalledTimes(1); + expect(harness.session.getProjectSkillTrust().decision).toBe("trusted"); + expect(kernelPythonSkills(harness)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ importName: "marker_skill", scope: "project" }), + expect.objectContaining({ importName: "web_search", scope: "user" }), + ]), + ); + expect(promptSkillEntry(harness, PROJECT_SKILL)).toContain("marker_skill"); + expect(notifications.some((n) => n.message.includes(PROJECT_SKILL) && n.type === "info")).toBe(true); + + // Re-binding (e.g. a client reattach) never asks a second time. + await harness.session.bindExtensions({ uiContext: ui }); + await new Promise((resolve) => setImmediate(resolve)); + expect(select).toHaveBeenCalledTimes(1); + }); + + it("asks again when a UI arrives after a prompt nobody could answer (daemon bind before attach)", async () => { + const { harness, store } = await createSkillHarness(); + // The daemon UI bridge resolves dialogs with undefined while no UI client is attached. + let answer: string | undefined; + const select = vi.fn(async () => answer); + const { ui, notifications } = createUi(select); + + await harness.session.bindExtensions({ uiContext: ui }); + await vi.waitFor(() => expect(select).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(store.getDecision(harness.tempDir)).toBe("undecided"); + expect(notifications).toEqual([]); + + // A UI-capable client attaches: the daemon re-asks and the user trusts the project. + answer = PROJECT_SKILL_TRUST_CHOICES.trust; + harness.session.promptProjectSkillTrust(); + await vi.waitFor(() => expect(store.getDecision(harness.tempDir)).toBe("trusted")); + expect(select).toHaveBeenCalledTimes(2); + expect(kernelImportNames(harness)).toEqual(expect.arrayContaining(["marker_skill", "web_search"])); + + // Decided: further attaches never ask again. + harness.session.promptProjectSkillTrust(); + await new Promise((resolve) => setImmediate(resolve)); + expect(select).toHaveBeenCalledTimes(2); + }); + + it("persists a 'never' answer and keeps the project skills disabled", async () => { + const { harness, store } = await createSkillHarness(); + const select = vi.fn(async () => PROJECT_SKILL_TRUST_CHOICES.never); + const { ui } = createUi(select); + + await harness.session.bindExtensions({ uiContext: ui }); + await vi.waitFor(() => expect(store.getDecision(harness.tempDir)).toBe("denied")); + + expect(kernelImportNames(harness)).toEqual(["web_search"]); + expect(promptSkillEntry(harness, PROJECT_SKILL)).not.toContain("python_import"); + }); + + it("does not persist a 'not now' answer or a dismissed prompt", async () => { + const { harness, store } = await createSkillHarness(); + const { ui, notifications } = createUi(async () => PROJECT_SKILL_TRUST_CHOICES.notNow); + + await harness.session.bindExtensions({ uiContext: ui }); + await vi.waitFor(() => expect(notifications.length).toBeGreaterThan(0)); + + expect(store.getDecision(harness.tempDir)).toBe("undecided"); + expect(notifications[0]).toMatchObject({ type: "warning" }); + expect(notifications[0]?.message).toContain(`/${PROJECT_SKILL_TRUST_COMMAND}`); + expect(kernelImportNames(harness)).toEqual(["web_search"]); + + // "Not now" holds for the rest of the session, even when another UI client attaches. + harness.session.promptProjectSkillTrust(); + await new Promise((resolve) => setImmediate(resolve)); + expect(notifications).toHaveLength(1); + }); + + it("applies a persisted trust decision at startup without prompting", async () => { + const store = createInMemoryProjectSkillTrustStore(); + const select = vi.fn(async () => PROJECT_SKILL_TRUST_CHOICES.never); + const { ui } = createUi(select); + // Decide before the session builds its runtime. + const resourceLoader = createTestResourceLoader(); + let skills: PythonSkill[] = []; + resourceLoader.getSkills = () => ({ skills, diagnostics: [] }); + const harness = await createHarness({ resourceLoader, projectSkillTrust: store }); + harnesses.push(harness); + store.setDecision(harness.tempDir, "trusted"); + skills = [pythonSkill(harness.tempDir, PROJECT_SKILL, "project")]; + await harness.session.reload(); + + await harness.session.bindExtensions({ uiContext: ui }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(select).not.toHaveBeenCalled(); + expect(kernelImportNames(harness)).toEqual(["marker_skill"]); + expect(promptSkillEntry(harness, PROJECT_SKILL)).toContain("marker_skill"); + }); + + it("/trust-project-skills changes the persisted decision and reloads the runtime", async () => { + const { harness, store } = await createSkillHarness({ withCommand: true }); + let reloads = 0; + await harness.session.bindExtensions({ + commandContextActions: { + waitForIdle: () => harness.session.waitForIdle(), + newSession: async () => ({ cancelled: false }), + fork: async () => ({ cancelled: false }), + navigateTree: async () => ({ cancelled: false }), + switchSession: async () => ({ cancelled: false }), + reload: async () => { + reloads += 1; + await harness.session.reload(); + }, + }, + }); + + await harness.session.prompt(`/${PROJECT_SKILL_TRUST_COMMAND} on`); + expect(store.getDecision(harness.tempDir)).toBe("trusted"); + expect(reloads).toBe(1); + expect(kernelImportNames(harness)).toEqual(expect.arrayContaining(["marker_skill", "web_search"])); + + await harness.session.prompt(`/${PROJECT_SKILL_TRUST_COMMAND} off`); + expect(store.getDecision(harness.tempDir)).toBe("denied"); + expect(reloads).toBe(2); + expect(kernelImportNames(harness)).toEqual(["web_search"]); + + await harness.session.prompt(`/${PROJECT_SKILL_TRUST_COMMAND} reset`); + expect(store.getDecision(harness.tempDir)).toBe("undecided"); + }); +});