Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/rlm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
10 changes: 10 additions & 0 deletions packages/coding-agent/docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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/<name>-<hash>`) 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
Expand Down
20 changes: 17 additions & 3 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -89,6 +93,7 @@ export interface AgentSessionServices {
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
mcpManager: McpManager;
projectSkillTrust: ProjectSkillTrustStore;
diagnostics: AgentSessionRuntimeDiagnostic[];
}

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -216,6 +228,7 @@ export async function createAgentSessionServices(
modelRegistry,
resourceLoader,
mcpManager,
projectSkillTrust,
diagnostics,
};
}
Expand All @@ -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,
Expand Down
120 changes: 119 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void> {
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<void> {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
});
};
}
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
hasFileBasedHerdrIntegration,
herdrAgentStateExtension,
} from "./builtin/herdr-agent-state.js";
export { createProjectSkillTrustExtension } from "./builtin/project-skill-trust.js";
export {
createExtensionRuntime,
discoverAndLoadExtensions,
Expand Down
Loading
Loading