fix: align ai agent permission modes

This commit is contained in:
lucaronin
2026-04-30 21:01:09 +02:00
parent 4a03f1675e
commit adcaa8a387
20 changed files with 200 additions and 68 deletions

View File

@@ -246,7 +246,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **Agent adapters** Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.

View File

@@ -442,7 +442,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103.
### Work with external MCP setup

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0103"
title: "Adapter-specific AI permission semantics"
status: active
date: 2026-04-30
supersedes: "0092"
---
## Context
ADR-0092 introduced per-vault Vault Safe / Power User modes, but the first implementation left too much room for adapter drift. Some agents can directly deny or allow Bash, some expose only a sandbox/approval profile, and Pi currently has no narrower app-managed switch beyond Tolaria's transient MCP configuration. The shared UI still needs a consistent product contract: Vault Safe must not encourage shell execution, while Power User should keep shell execution available across repeated agent turns where the selected adapter supports it.
## Decision
**Tolaria treats the permission mode as a product contract first and maps it conservatively per adapter.**
- Shared AI system prompts are mode-aware on every turn, including turns with note context snapshots.
- Vault Safe tells agents not to use or advertise shell, terminal, Bash, script execution, git, or command-line tools.
- Power User tells shell-capable agents that local shell commands are available for the active vault and should remain scoped to that vault.
- Claude Code Safe excludes Bash; Power User includes and pre-approves Bash without dangerous bypass flags.
- Codex Safe uses the CLI's read-only sandbox plus untrusted approval policy; Power User uses workspace-write plus never-ask approval so shell-capable Codex turns remain low-friction across the session.
- OpenCode Safe denies bash and external directories; Power User allows bash while still denying external directories.
- Pi keeps the same conservative transient MCP config in both modes until the Pi CLI exposes a reliable app-managed shell permission switch. The prompt must not promise shell for Pi Power User.
- Gemini Safe excludes `run_shell_command`; Gemini Power User intentionally uses `yolo` with trusted transient Tolaria MCP settings.
## Consequences
- Mode behavior is no longer described solely by generic UI copy; adapter docs and tests define the exact mapping.
- Codex Vault Safe remains a best-effort safe profile rather than a true built-in-tools-off mode, because Codex CLI currently exposes sandbox and approval controls but not a dedicated switch to remove shell tooling while preserving MCP.
- Future adapters must either implement both modes explicitly or document that Power User maps to the same conservative behavior.
- If Tolaria adds a stronger warning or dangerous mode later, it needs a separate ADR and UI language.

View File

@@ -144,7 +144,7 @@ proposed → active → superseded
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | superseded -> [0103](0103-adapter-specific-ai-permission-semantics.md) |
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
@@ -155,3 +155,4 @@ proposed → active → superseded
| [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active |
| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active |
| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active |
| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active |

View File

@@ -2,9 +2,8 @@
/**
* Tolaria MCP Server — lightweight vault tools for AI agents.
*
* The agent has full shell access (bash, read, write, edit).
* These MCP tools provide Tolaria-specific capabilities that
* native tools cannot replace:
* These MCP tools provide Tolaria-specific capabilities alongside each
* app-managed agent's own Safe / Power User permission profile:
*
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)

View File

@@ -1,6 +1,7 @@
/**
* Vault operations — read-only helpers for Laputa markdown vault.
* Write operations are handled by the agent's native bash/write/edit tools.
* Vault operations — read-only helpers for Tolaria markdown vault.
* Write operations are handled by the app-managed agent's active permission
* profile and native file-edit tools when available.
*/
import fs from 'node:fs/promises'
import path from 'node:path'

View File

@@ -29,19 +29,13 @@ where
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(codex_binary_candidates())? {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
find_codex_binary_on_path()
.filter(is_usable_codex_binary)
.or_else(|| find_codex_binary_in_user_shell().filter(is_usable_codex_binary))
.or_else(|| find_usable_codex_binary(codex_binary_candidates()))
.ok_or_else(|| {
"Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()
})
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
@@ -139,8 +133,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<Pa
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Result<Option<PathBuf>, String> {
crate::cli_agent_runtime::find_executable_binary_candidate(candidates, "Codex CLI")
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(is_usable_codex_binary)
}
fn is_usable_codex_binary(binary: &PathBuf) -> bool {
crate::cli_agent_runtime::version_for_binary(binary).is_some()
}
fn run_agent_stream_with_binary<F>(
@@ -186,9 +184,9 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String>
Ok(vec![
"--sandbox".into(),
"workspace-write".into(),
codex_sandbox(request.permission_mode).into(),
"--ask-for-approval".into(),
"never".into(),
codex_approval_policy(request.permission_mode).into(),
"exec".into(),
"--json".into(),
"-C".into(),
@@ -205,6 +203,20 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String>
])
}
fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
match permission_mode {
crate::ai_agents::AiAgentPermissionMode::Safe => "read-only",
crate::ai_agents::AiAgentPermissionMode::PowerUser => "workspace-write",
}
}
fn codex_approval_policy(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
match permission_mode {
crate::ai_agents::AiAgentPermissionMode::Safe => "untrusted",
crate::ai_agents::AiAgentPermissionMode::PowerUser => "never",
}
}
fn build_codex_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
@@ -276,7 +288,7 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
}
if is_codex_write_permission_error(&lower) {
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
@@ -331,13 +343,10 @@ mod tests {
}
}
fn assert_codex_workspace_write_contract(args: &[String]) {
let prefix = [
"--sandbox",
"workspace-write",
"--ask-for-approval",
"never",
];
fn assert_codex_permission_contract(args: &[String], permission_mode: AiAgentPermissionMode) {
let sandbox = codex_sandbox(permission_mode);
let approval = codex_approval_policy(permission_mode);
let prefix = ["--sandbox", sandbox, "--ask-for-approval", approval];
assert_eq!(&args[..prefix.len()], prefix);
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
@@ -396,26 +405,21 @@ mod tests {
permission_mode: AiAgentPermissionMode::Safe,
}) {
assert_eq!(args[4], "exec");
assert_codex_workspace_write_contract(&args);
assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe);
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
for permission_mode in [
AiAgentPermissionMode::Safe,
AiAgentPermissionMode::PowerUser,
] {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode,
}) {
assert_codex_workspace_write_contract(&args);
}
fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::PowerUser,
}) {
assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser);
}
}
@@ -522,6 +526,18 @@ exit 2
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[cfg(unix)]
#[test]
fn usable_codex_binary_skips_broken_shims() {
let dir = tempfile::tempdir().unwrap();
let broken = executable_script(dir.path(), "broken-codex", "exit 1\n");
let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n");
let found = find_usable_codex_binary(vec![broken, working.clone()]);
assert_eq!(found, Some(working));
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -850,9 +850,11 @@ mod tests {
let allowed_roots = vec![vault_a.clone()];
assert_eq!(
missing_asset_scope_roots(&allowed_roots, &[vault_b.clone()]),
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_b)),
vec![vault_b]
);
assert!(missing_asset_scope_roots(&allowed_roots, &[vault_a]).is_empty());
assert!(
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
);
}
}

View File

@@ -1052,7 +1052,7 @@ describe('App', () => {
await waitFor(() => {
expect(getHeader()).toHaveTextContent('Inbox')
})
})
}, 10_000)
it('opens favorites directly into Neighborhood mode', async () => {
configureNeighborhoodFavoritesVault()

View File

@@ -102,7 +102,7 @@ describe('SettingsPanel', () => {
expect(screen.getByText('设置')).toBeInTheDocument()
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
})
}, 10_000)
it('calls onSave with stable defaults on save', () => {
render(

View File

@@ -103,7 +103,7 @@ describe('StatusBar', () => {
it('build number shows the update tooltip on focus', async () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={vi.fn()} />)
await expectTooltip(screen.getByRole('button', { name: 'Check for updates' }), 'Check for updates')
})
}, 10_000)
it('does not display branch name', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)

View File

@@ -70,7 +70,7 @@ describe('TypeCustomizePopover', () => {
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('Template')).toBeInTheDocument()
expect(screen.getByText('Done')).toBeInTheDocument()
})
}, 10_000)
it('can hide the template and Done controls for inline appearance editing', () => {
renderPopover({ showTemplate: false, showDone: false, surface: 'inline' })

View File

@@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCliAiAgent } from './useCliAiAgent'
import { streamAiAgent } from '../utils/streamAiAgent'
import { buildAgentSystemPrompt } from '../utils/ai-agent'
vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(),
@@ -12,6 +13,7 @@ vi.mock('../utils/ai-agent', () => ({
}))
const mockStreamAiAgent = vi.mocked(streamAiAgent)
const mockBuildAgentSystemPrompt = vi.mocked(buildAgentSystemPrompt)
const VAULT = '/Users/luca/Laputa'
function renderAgent(
@@ -48,8 +50,13 @@ describe('useCliAiAgent', () => {
})
expect(result.current.sendMessage).not.toBe(firstSendMessage)
expect(mockBuildAgentSystemPrompt).toHaveBeenCalledWith({
agent: 'codex',
permissionMode: 'safe',
vaultContext: 'You are viewing note with body: Hello world',
})
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
systemPrompt: 'You are viewing note with body: Hello world',
systemPrompt: 'default-system-prompt',
}))
})

View File

@@ -115,12 +115,16 @@ describe('aiAgentConversation', () => {
]
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault' },
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
messages,
{ text: 'Latest question' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledTimes(1)
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
permissionMode: 'safe',
vaultContext: undefined,
})
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
@@ -135,15 +139,25 @@ describe('aiAgentConversation', () => {
})
})
it('prefers a system prompt override when provided', () => {
it('appends context snapshots to the mode-aware system prompt', () => {
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', systemPromptOverride: 'OVERRIDE' },
{
agent: 'codex',
ready: true,
vaultPath: '/vault',
permissionMode: 'power_user',
systemPromptOverride: 'CONTEXT',
},
[],
{ text: 'Prompt' },
)
expect(buildAgentSystemPromptMock).not.toHaveBeenCalled()
expect(result.systemPrompt).toBe('OVERRIDE')
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
permissionMode: 'power_user',
vaultContext: 'CONTEXT',
})
expect(result.systemPrompt).toBe('SYSTEM')
})
})

View File

@@ -110,7 +110,11 @@ export function buildFormattedMessage(
messages: AiAgentMessage[],
prompt: PendingUserPrompt,
): { formattedMessage: string; systemPrompt: string } {
const systemPrompt = context.systemPromptOverride ?? buildAgentSystemPrompt()
const systemPrompt = buildAgentSystemPrompt({
agent: context.agent,
permissionMode: context.permissionMode,
vaultContext: context.systemPromptOverride,
})
const chatHistory = toChatHistory(messages.filter((message) => !message.isStreaming))
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)

View File

@@ -133,7 +133,7 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
expect(streamAiAgentMock).toHaveBeenCalledWith({
agent: 'codex',
message: 'formatted:Latest question',
systemPrompt: 'OVERRIDE',
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
permissionMode: 'power_user',
callbacks: { stream: 'callbacks' },
@@ -256,6 +256,11 @@ describe('aiAgentSession', () => {
expectStreamingRuntimeState(session)
expectFormattedHistoryUsed()
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
permissionMode: 'power_user',
vaultContext: 'OVERRIDE',
})
expectStreamingRequest(session.runtime)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_sent', {
agent: 'codex',

View File

@@ -146,6 +146,7 @@ describe('aiAgentStreamCallbacks', () => {
})
callbacks.onError('boom')
callbacks.onDone()
expect(status.getStatus()).toBe('error')
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_failed', {
@@ -169,6 +170,7 @@ describe('aiAgentStreamCallbacks', () => {
response: 'Partial reply\n\nError: boom',
},
])
expect(trackEventMock).not.toHaveBeenCalledWith('ai_agent_response_completed', expect.anything())
})
it.each([

View File

@@ -44,6 +44,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
fileCallbacksRef,
} = context
let failureTracked = false
let streamFailed = false
return {
onThinking: (chunk: string) => {
@@ -97,6 +98,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
if (abortRef.current.aborted) return
setStatus('error')
streamFailed = true
const partial = responseAccRef.current
failureTracked = true
trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size)
@@ -113,6 +115,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
onDone: () => {
if (abortRef.current.aborted) return
if (streamFailed) return
setStatus('done')
const finalResponse = finalResponseText(responseAccRef.current, agent)

View File

@@ -9,7 +9,8 @@ describe('buildAgentSystemPrompt', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('working inside Tolaria')
expect(prompt).toContain('active vault')
expect(prompt).toContain('Avoid shell commands')
expect(prompt).toContain('Vault Safe mode is active')
expect(prompt).toContain('not available in Vault Safe')
expect(prompt).not.toContain('full shell access')
expect(prompt).not.toContain('Vault context')
})
@@ -21,6 +22,19 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toContain('Recent notes: foo, bar')
})
it('allows shell commands in power user mode where supported', () => {
const prompt = buildAgentSystemPrompt({ agent: 'codex', permissionMode: 'power_user' })
expect(prompt).toContain('Power User mode is active')
expect(prompt).toContain('Local shell commands are available')
expect(prompt).not.toContain('not available in Vault Safe')
})
it('does not promise shell execution for Pi power user mode', () => {
const prompt = buildAgentSystemPrompt({ agent: 'pi', permissionMode: 'power_user' })
expect(prompt).toContain('Pi currently uses the same conservative Tolaria MCP configuration')
expect(prompt).not.toContain('Local shell commands are available')
})
it('instructs AI to use wikilink syntax', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('[[')

View File

@@ -1,5 +1,8 @@
import type { AiAgentId } from '../lib/aiAgents'
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
/**
* AI Agent utilities — Claude CLI agent mode with scoped file tools + MCP vault tools.
* AI Agent utilities for app-managed CLI agent sessions.
*
* App-managed sessions can edit files in the active vault and use Tolaria-specific
* MCP tools (search_notes, get_vault_context, get_note, open_note).
@@ -8,18 +11,47 @@
// --- Agent system prompt ---
interface AgentSystemPromptOptions {
vaultContext?: string
permissionMode?: AiAgentPermissionMode
agent?: AiAgentId
}
function normalizePromptOptions(
options?: string | AgentSystemPromptOptions,
): AgentSystemPromptOptions {
return typeof options === 'string' ? { vaultContext: options } : options ?? {}
}
function permissionModeInstructions(
mode: AiAgentPermissionMode = 'safe',
agent?: AiAgentId,
): string {
if (mode === 'power_user') {
if (agent === 'pi') {
return `Power User mode is selected, but Pi currently uses the same conservative Tolaria MCP configuration in both modes. Do not promise shell execution unless the Pi CLI exposes it directly in this run.`
}
return `Power User mode is active. Local shell commands are available for this vault where the selected CLI agent supports them. Keep commands scoped to the active vault, avoid destructive commands unless explicitly requested, and do not expose note content unnecessarily.`
}
return `Vault Safe mode is active. Do not use shell, terminal, Bash, Python/Node script execution, git, or command-line tools. If the user asks whether shell commands are available, say they are not available in Vault Safe. Use file/search/edit tools and Tolaria MCP tools instead.`
}
const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a personal knowledge management app.
Notes are markdown files with YAML frontmatter. Standard fields: title, type (aliased is_a), date, tags.
You can edit markdown files in the active vault. Prefer file edit tools for note changes.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
Avoid shell commands; app-managed sessions are intentionally scoped to vault file edits and Tolaria MCP tools.
When you create or edit a note, call open_note(path) so the user sees it in Tolaria.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
export function buildAgentSystemPrompt(vaultContext?: string): string {
if (!vaultContext) return AGENT_SYSTEM_PREAMBLE
return `${AGENT_SYSTEM_PREAMBLE}\n\nVault context:\n${vaultContext}`
export function buildAgentSystemPrompt(options?: string | AgentSystemPromptOptions): string {
const { vaultContext, permissionMode, agent } = normalizePromptOptions(options)
const prompt = `${AGENT_SYSTEM_PREAMBLE}\n\n${permissionModeInstructions(permissionMode, agent)}`
if (!vaultContext) return prompt
return `${prompt}\n\nVault context:\n${vaultContext}`
}