Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
6d28571
feat: add default_project_id column to studios table
conoremclaughlin Jun 19, 2026
2cff6c7
feat: add projectName filter and studio default project inheritance
conoremclaughlin Jun 19, 2026
4ea152f
fix: validate defaultProjectId ownership at studio and task group bou…
conoremclaughlin Jun 20, 2026
3cdc157
feat: add execution_phase tracking and executionMode for strategy exe…
conoremclaughlin Jun 20, 2026
000a127
fix: add studio ownership guard in handleUpdateStudio
conoremclaughlin Jun 20, 2026
2544fd3
fix: propagate caller metadata through trigger payload to server handler
conoremclaughlin Jun 20, 2026
0174c6a
feat: add projectName filter and studio default project ID (by Wren) …
conoremclaughlin Jun 20, 2026
219c134
feat: fix strategy execution gap with execution_phase + executionMode…
conoremclaughlin Jun 20, 2026
58fc7a9
feat: add DEFAULT_CLAUDE_MODEL env var for spawned session model pinn…
conoremclaughlin Jun 20, 2026
ce5a371
fix: pre-compute Gemini context token instead of env var interpolatio…
conoremclaughlin Jun 20, 2026
5f85059
feat: integrate Pi coding tools into ink CLI local tool routing (by W…
conoremclaughlin Jun 20, 2026
b12bf85
feat: add group:read and group:write policy groups for Pi coding tool…
conoremclaughlin Jun 20, 2026
2b252d2
feat: add Tool Policy visualization page to web dashboard (by Wren)
conoremclaughlin Jun 20, 2026
8b5b997
feat: add ink policy CLI command with show/profiles/groups/matrix (by…
conoremclaughlin Jun 20, 2026
47718a8
feat: add --away flag and switch InkRunner to safe profile with 2FA (…
conoremclaughlin Jun 20, 2026
8f098a1
test: add Pi tool policy tests and remove dead safeSpecs loop (by Wren)
conoremclaughlin Jun 20, 2026
2190e3e
docs: add Tool Policy and 2FA Architecture sections to ARCHITECTURE.m…
conoremclaughlin Jun 20, 2026
81a0baf
feat: 2FA approval UX v2 — batch notifications, scoped grants, rich c…
conoremclaughlin Jun 20, 2026
235549c
test: add 2FA approval UX v2 integration tests (by Wren)
conoremclaughlin Jun 20, 2026
8c43c04
fix: persistent grants use promptTools removal to avoid narrowing fil…
conoremclaughlin Jun 20, 2026
1a2c02b
fix: permanentGrants survive profile re-application across heartbeat …
conoremclaughlin Jun 20, 2026
6016568
fix: add path containment for Pi tools + fix Gemini adapter test (by …
conoremclaughlin Jun 21, 2026
ba9bb10
feat: add TTS provider logging + per-call voice override for Myra (by…
conoremclaughlin Jun 21, 2026
37b6bf9
test: add TTS voice override and logging tests (by Wren)
conoremclaughlin Jun 21, 2026
e5f017c
feat: expose voiceReply and ttsVoice on send_response MCP tool (by Wren)
conoremclaughlin Jun 22, 2026
0f54caf
feat: add read_pdf MCP tool for PDF text extraction
conoremclaughlin Jun 23, 2026
3fb1e23
feat: grant Ink sessions read access to ~/.ink/files/ by default (by …
conoremclaughlin Jun 24, 2026
b0eda8a
feat: add PDF read adapter to Pi tools — read extracts text from PDFs…
conoremclaughlin Jun 24, 2026
5c2e7d2
feat: add PDF read adapter to server-side Pi coding tools (by Wren)
conoremclaughlin Jun 24, 2026
5a52b05
refactor: remove read_pdf MCP tool — runtime handles local file reads…
conoremclaughlin Jun 24, 2026
8868125
test: add PDF path containment tests for Pi read adapter (by Wren)
conoremclaughlin Jun 24, 2026
c5d0fd0
feat: include agent name in approval confirmation messages (by Wren)
conoremclaughlin Jun 24, 2026
0b17342
fix: resolve 'unknown' agent in approval requests via body fallback (…
conoremclaughlin Jun 24, 2026
f5be10c
fix: resolve agent identity server-side for approval requests (by Wren)
conoremclaughlin Jun 24, 2026
ebf6909
fix: synthesize x-ink-context when missing in approval hooks (by Wren)
conoremclaughlin Jun 24, 2026
05e4804
fix: identify integration tests in approval requests as test:2fa-inte…
conoremclaughlin Jun 24, 2026
31daa13
fix: extract shared path containment + fix server-side symlink bypass…
conoremclaughlin Jun 24, 2026
68553f8
fix: scope approval resolution to anchor request's agent/session/stud…
conoremclaughlin Jun 24, 2026
991f811
fix: scope persistentGrant to target scope + pass args through approv…
conoremclaughlin Jun 24, 2026
63794bb
test: fix pi-tools approval flow assertion for 3-arg promptForApprova…
conoremclaughlin Jun 24, 2026
8d63c25
fix: integration tests import from dist/ instead of stale src/*.js + …
conoremclaughlin Jun 24, 2026
7aec33d
Merge remote-tracking branch 'origin/main' into wren/feat/pi-coding-t…
conoremclaughlin Jun 24, 2026
94cdd34
fix: remove duplicate executionMode declaration from merge (by Wren)
conoremclaughlin Jun 24, 2026
d72d89b
feat: per-agent TTS voice config via tts_config on agent_identities (…
conoremclaughlin Jun 25, 2026
3f57e27
feat: expose ttsConfig on save_identity and get_identity (by Wren)
conoremclaughlin Jun 25, 2026
9859988
fix: normalize bare dates to RFC 3339 for Google Calendar API (by Wren)
conoremclaughlin Jun 25, 2026
e0fd1e7
fix: timezone-aware bare-date resolution for calendar queries (by Wren)
conoremclaughlin Jun 25, 2026
7dc3863
feat: improve calendar error reporting + timezone docs for bare dates…
conoremclaughlin Jun 25, 2026
b5743c3
fix: prevent cross-agent session routing + correct mission feed backe…
conoremclaughlin Jun 25, 2026
3ff4682
fix: make channel listener startup non-fatal with auto-retry (by Wren)
conoremclaughlin Jun 29, 2026
8c2b784
fix: address Lumen review — approval args visibility, scope leak, tar…
conoremclaughlin Jun 29, 2026
1f5e47f
fix: surface auth failure instead of misleading legacy 404 in PcpClie…
conoremclaughlin Jul 2, 2026
7b0c722
fix: pass server-minted access token to spawned ink sessions (by Wren)
conoremclaughlin Jul 2, 2026
c2302b0
fix: address Lumen re-review blockers on PR #346 (by Wren)
conoremclaughlin Jul 3, 2026
d27bd6d
fix: permanentGrants override promptTools, async existsSync, exclude …
conoremclaughlin Jul 3, 2026
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
204 changes: 196 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,26 +139,41 @@ Wren calls send_to_inbox() + trigger: true
→ Myra processes, responds via ChannelGateway
```

### SB CLI → Claude Code
### SB CLI → Claude Code (direct backend)

```
sb "fix the bug" → Identity injection (--append-system-prompt)
→ Spawns claude with Inkwell identity + MCP config
→ Claude Code connects to MCP server at localhost:3001
→ Agent bootstraps, remembers who it is
→ Full native tools: Read, Edit, Write, Bash, MCP
```

### Ink Backend → Local Tool Routing (Myra, Benson)

````
InkRunner → ink chat --non-interactive --approval-mode auto-approve
→ ink CLI spawns Claude Code with --allowedTools '' (tools OFF)
→ LLM generates text with ```ink-tool blocks
→ ink CLI extracts + executes via PCP server HTTP
→ Results injected as context → next LLM turn
→ No filesystem access, Inkwell tools only
````

## Multi-Agent Identity

Three agents share the same infrastructure with distinct identities and filtered memories:
Six agents share the same infrastructure with distinct identities, backends, and filtered memories:

| Agent | Interface | Nature |
| ---------- | ---------------------- | -------------------------------------- |
| **Wren** | Claude Code (via `sb`) | Session-based development collaborator |
| **Myra** | Telegram / WhatsApp | Persistent messaging bridge |
| **Benson** | Discord / Slack | Conversational partner |
| Agent | Interface | Backend | Provider | Nature |
| ---------- | ---------------------- | ----------- | ----------- | -------------------------------------- |
| **Wren** | Claude Code (via `sb`) | claude-code | claude-code | Session-based development collaborator |
| **Lumen** | Codex CLI | codex | codex | Development collaborator |
| **Aster** | Gemini CLI | gemini | gemini | Development collaborator |
| **Myra** | Telegram / WhatsApp | ink | claude-code | Persistent messaging bridge |
| **Benson** | Discord / Slack | claude | claude | Conversational partner |
| **Echo** | (test only) | — | — | Integration test agent |

Identity is resolved from: system prompt override → `$AGENT_ID` env var → `.ink/identity.json` → `~/.ink/config.json`. Each agent has identity files at `~/.ink/individuals/<agentId>/` and memories filtered by agentId.
Identity is resolved from: system prompt override → `$AGENT_ID` env var → `.ink/identity.json` → `~/.ink/config.json`. Identity documents (SOUL, HEARTBEAT, IDENTITY) live in the database (`agent_identities` table), with `~/.ink/` as a fallback cache. Memories are filtered by agentId (plus shared memories where `agentId` is null).

## MCP Tools

Expand Down Expand Up @@ -210,6 +225,177 @@ Identity is resolved from: system prompt override → `$AGENT_ID` env var → `.

For production, use `yarn prod:direct` or Docker Compose (`docker-compose.app.yml`).

## Session Runners and Tool Routing (2026-06-15)

The server supports multiple runtime backends. Each agent's `backend` and `provider` columns in `agent_identities` determine which runner and LLM are used.

### Runner Selection

| Backend value | Runner | What it spawns | Tool access |
| ------------- | -------------- | --------------------- | --------------------------------------- |
| `claude-code` | `ClaudeRunner` | `claude` CLI directly | Native CC tools (Read, Edit, Bash, MCP) |
| `codex-cli` | `CodexRunner` | `codex` CLI directly | Native Codex tools |
| `gemini` | `GeminiRunner` | `gemini` CLI directly | Native Gemini tools |
| `ink` | `InkRunner` | `ink chat` CLI | Ink local tool routing (see below) |

The `provider` column (separate from `backend`) determines which LLM model family is used. For `ink` backend sessions, `provider` selects the underlying CLI (e.g., `provider: 'claude-code'` means `ink chat` spawns Claude Code as its LLM).

### Current Agent Configuration

| Agent | Backend | Provider | sandbox_bypass |
| ------ | ----------- | ----------- | -------------- |
| wren | claude-code | claude-code | false |
| myra | ink | claude-code | false |
| lumen | codex | codex | true |
| benson | claude | claude | false |
| aster | gemini | gemini | false |

### Ink Backend: Local Tool Routing

When `backend = 'ink'`, the session flows through the ink CLI's local tool routing architecture:

````
InkRunner spawns: ink chat --non-interactive --approval-mode auto-approve
└─ ink CLI sets toolRouting: 'local' (default)
└─ Spawns Claude Code with --allowedTools '' (EMPTY — all native tools disabled)
└─ LLM generates text with fenced ink-tool blocks:
```ink-tool
{"tool":"recall","args":{"query":"..."}}
```
└─ ink CLI extracts blocks, executes via PCP server HTTP
└─ Results fed back as context for next LLM turn
└─ Loop repeats (max 5 iterations) until no tool blocks emitted
````

**Key implications:**

- Claude Code's native tools (Read, Edit, Write, Bash, Agent, etc.) are **completely disabled** via `--allowedTools ''`
- The LLM is a pure text generator — no filesystem access, no shell execution
- Available tools are Inkwell MCP tools only: `recall`, `remember`, `send_response`, `get_inbox`, etc.
- `.claude/settings.local.json` permissions are **irrelevant** — the empty allowlist overrides everything
- Tool policy and approval happen at the ink CLI layer, not Claude Code's permission system

### Tool Policy & Profiles

The ink CLI enforces a tool policy system (`ToolPolicyState`) that controls which tools agents can call. Profiles are predefined policy configurations applied via `--profile <name>`:

| Profile | Mode | Behavior |
| --------------- | ---------- | ------------------------------------------------------------------------ |
| `minimal` | backend | Read-only. `group:read` allowed, comms/writes denied. Allowlist narrows. |
| `safe` | backend | All tools allowed except comms and writes, which require 2FA approval. |
| `collaborative` | backend | Everything allowed. No prompts, no restrictions. |
| `full` | privileged | All tools allowed, policy bypassed entirely. |

**Policy decision flow** (`canCallPcpTool`):

1. Deny list → blocked (not promptable)
2. Privileged mode → allowed
3. Session grant → allowed
4. Scoped grant → allowed (one-time use, decrements)
5. Prompt list → blocked, promptable (2FA path)
6. Allow-list narrowing → if allowTools is non-empty and tool isn't in it, blocked+promptable
7. Default → allowed

**Key design property:** `safeTools` (DEFAULT_SAFE_PCP_TOOLS) do NOT create narrowing. Only explicit `allowTools` entries create a whitelist filter. This means profiles like `safe` that have empty `allowSpecs` allow all MCP tools by default — only `promptSpecs` gates specific tools.

**Tool groups** define logical sets expanded at policy application time:

| Group | Tools |
| ------------------- | ------------------------------------------------------ |
| `group:ink-safe` | bootstrap, recall, get_inbox, list_sessions, etc. |
| `group:ink-comms` | send_to_inbox, trigger_agent, send_response |
| `group:ink-memory` | remember, recall, forget, update_memory, etc. |
| `group:ink-session` | start_session, update_session_state, end_session, etc. |
| `group:read` | read, grep, find, ls |
| `group:write` | edit, write, bash |

InkRunner spawns with `--profile safe --away`, meaning server-spawned agents can read freely, call MCP tools, but must get human approval for file writes and cross-agent communication.

### Tool Approval (2FA)

The ink CLI has a multi-tier approval system for tool calls:

1. **Interactive mode** — TUI prompt when a human is at the terminal
2. **JSONL mode** — Structured protocol for programmatic integrations: `approval_request` on stderr, `approval_response` on stdin
3. **Remote 2FA (away mode)** — Server-routed approval via connected platforms (Telegram, WhatsApp)

#### 2FA Architecture

When `--away` is set, tool calls requiring approval trigger a server-side 2FA flow:

```
Agent calls tool → canCallPcpTool returns promptable
→ CLI creates approval_requests DB record via POST /api/admin/approval-requests
→ Server calls notifyPlatformOfApprovalRequest()
→ Looks up user's trusted_users (Telegram, WhatsApp)
→ Sends formatted notification directly via Telegram Bot API
→ Stores telegramMessageId in request metadata (for reply-to threading)
→ CLI polls GET /api/admin/approval-requests/:requestId/status (every 3s, 5min timeout)
→ User replies on Telegram: "approve" / "deny" / "approve session"
→ approval-interceptor.ts catches reply BEFORE agent routing
→ Matches against pending approval_requests by user + reply-to thread
→ Updates DB: status, action, granted_tools, granted_by, resolved_at
→ Sends ack emoji back to user
→ CLI poll picks up resolution → tool allowed or denied
```

**Security properties:**

- `permission_grant` messages can ONLY originate from the system layer (verified platform identity). Agents cannot forge grants — enforced in `inbox-handlers.ts`.
- No HTTP endpoint for grant resolution — prevents client-side spoofing.
- Optimistic lock on DB updates — prevents double-approval races.
- The approval interceptor runs BEFORE agent routing — the user's "approve" reply never reaches the agent.
- Fails closed: any error during creation or polling results in denial.

**Key files:**

- `packages/cli/src/repl/approval-api.ts` — Shared HTTP client for create + poll
- `packages/api/src/channels/approval-interceptor.ts` — Platform response interception
- `supabase/migrations/20260416232802_approval_requests.sql` — DB schema
- `packages/api/src/routes/admin.ts` — REST endpoints for approval lifecycle
- `packages/cli/src/commands/hooks.ts` — PreToolUse hook (Claude Code integration)
- `packages/cli/src/repl/permission-grant.ts` — Grant payload structure and application

### Model Override

Default model for spawned sessions is controlled by env vars in `.env.local`:

```
DEFAULT_CLAUDE_MODEL=claude-opus-4-6 # Claude Code / ink+claude sessions
DEFAULT_CODEX_MODEL=... # Codex sessions
DEFAULT_GEMINI_MODEL=... # Gemini sessions
```

When set, these flow through `SessionServiceConfig` → runner config → `--model` flag on the spawned CLI process. When unset, the CLI uses its own default (which can change without warning — set explicitly for stability).

### Pi Coding Tools (2026-06-16)

The ink CLI integrates [@mariozechner/pi-coding-agent](https://github.com/badlogic/pi-mono)'s coding tools in-process, giving ink-backend agents filesystem access through the same local tool routing layer used for Inkwell tools.

````
LLM emits: ```ink-tool {"tool":"read","args":{"path":"src/server.ts"}} ```
→ ink CLI extracts ink-tool block
→ isPiTool("read") → true
→ callPiTool("read", args, cwd) → Pi tool executes in-process
→ Result injected as context → next LLM turn
````

**Available coding tools** (from Pi, scoped to working directory):

| Tool | What it does |
| ------- | -------------------------------------------------------- |
| `read` | Read file with offset/limit, line numbers, truncation |
| `edit` | Find-and-replace with conflict detection and diff output |
| `write` | Create/overwrite file with directory creation |
| `bash` | Execute shell command with timeout and output truncation |
| `grep` | Search file contents with regex and context lines |
| `find` | Find files by pattern, gitignore-aware |
| `ls` | List directory with file sizes and depth control |

Pi tools are initialized lazily per working directory via `createReadTool(cwd)`, `createEditTool(cwd)`, etc. The tool policy and 2FA approval flow still applies — Pi tools flow through the same `executeToolCalls` pipeline as Inkwell tools.

**Design decision:** Import Pi's tool implementations rather than reimplementing. Pi's tools are battle-tested in OpenClaw production with edge-case handling (encoding detection, binary safety, diff formatting, symlink guards) that would take significant effort to replicate. We only use the tool layer — not Pi's agent loop, session management, or LLM abstraction.

## Key Design Decisions

1. **Stateless SessionService** — All state in the database. Processing locks prevent races. Enables horizontal scaling.
Expand All @@ -218,3 +404,5 @@ For production, use `yarn prod:direct` or Docker Compose (`docker-compose.app.ym
4. **Channel-agnostic routing** — SessionService doesn't know about Telegram/WhatsApp. ChannelGateway handles routing.
5. **Heartbeat via SessionService** — Reminders are just messages. Same processing pipeline, same agent capabilities.
6. **Identity injection** — The `sb` CLI injects identity via system prompt. The agent bootstraps from there.
7. **Local tool routing as default for ink backend** — (2026-06-15) The ink CLI intercepts tool calls at its own layer rather than delegating to the underlying LLM CLI's native tools. This gives the ink CLI control over approval, policy, credential injection, and tool execution.
8. **Pi coding tools over reimplementation** — (2026-06-16) Rather than building filesystem tools from scratch, the ink CLI imports Pi's battle-tested tool implementations in-process. Same local tool routing, different executor for coding tools vs Inkwell tools. Leverages others' effort for the subtleties of code editing (diff formatting, truncation, encoding detection).
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"link-preview-js": "^4.0.1",
"node-cron": "^4.2.1",
"node-diff3": "^3.2.0",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"telegraf": "^4.15.0",
Expand Down
98 changes: 96 additions & 2 deletions packages/api/src/agent/tools/pi-coding-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import path from 'path';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, symlinkSync, existsSync } from 'fs';
import { rm } from 'fs/promises';
import { tmpdir } from 'os';
import { createInkCodingTools, type InkToolDefinition } from './pi-coding-tools';
import { resetProcessRegistry, getProcessRegistry } from './bash-guard';
Expand Down Expand Up @@ -123,6 +124,63 @@ describe('Pi Coding Tools Adapter', () => {
});
});

describe('PDF read adapter', () => {
it('extracts text from a PDF file', async () => {
const stream = 'BT /F1 12 Tf 100 700 Td (Hello from PDF) Tj ET';
const streamBytes = Buffer.from(stream);
const objects = [
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj',
'2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj',
`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj`,
`4 0 obj\n<< /Length ${streamBytes.length} >>\nstream\n${stream}\nendstream\nendobj`,
'5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj',
];
let body = '';
const offsets: number[] = [];
const header = '%PDF-1.4\n';
let pos = header.length;
for (const obj of objects) {
offsets.push(pos);
body += obj + '\n';
pos += Buffer.byteLength(obj + '\n');
}
const xrefStart = pos;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) {
xref += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;

writeFileSync(path.join(testDir, 'test.pdf'), header + body + xref);

const readTool = tools.find((t) => t.schema.name === 'read')!;
const result = await readTool.execute({ path: 'test.pdf' });
expect(result).toContain('Hello from PDF');
expect(result).toContain('[PDF:');
expect(result).toContain('1 page');
});

it('does not intercept non-PDF files', async () => {
const readTool = tools.find((t) => t.schema.name === 'read')!;
const result = await readTool.execute({ path: 'hello.txt' });
expect(result).toContain('Hello, world!');
expect(result).not.toContain('[PDF:');
});

it('blocks PDF read with absolute path outside workspace', async () => {
const readTool = tools.find((t) => t.schema.name === 'read')!;
const result = await readTool.execute({ path: '/tmp/secret.pdf' });
expect(result).toContain('Access denied');
expect(result).toContain('outside workspace root');
});

it('blocks PDF read with ../ traversal', async () => {
const readTool = tools.find((t) => t.schema.name === 'read')!;
const result = await readTool.execute({ path: '../../etc/secret.pdf' });
expect(result).toContain('Access denied');
});
});

describe('workspace root enforcement', () => {
it('blocks absolute path escape', async () => {
const readTool = tools.find((t) => t.schema.name === 'read')!;
Expand All @@ -137,6 +195,42 @@ describe('Pi Coding Tools Adapter', () => {
expect(result).toContain('Access denied');
});

it('blocks symlink escaping workspace (read)', async () => {
const outsideDir = mkdtempSync(path.join(tmpdir(), 'pi-outside-'));
writeFileSync(path.join(outsideDir, 'secret.txt'), 'TOP SECRET');
const linkPath = path.join(testDir, 'escape-link');
symlinkSync(outsideDir, linkPath);

try {
const readTool = tools.find((t) => t.schema.name === 'read')!;
const result = await readTool.execute({ path: 'escape-link/secret.txt' });
expect(result).toContain('Access denied');
expect(result).toContain('outside workspace root');
} finally {
await rm(linkPath, { force: true });
await rm(outsideDir, { recursive: true, force: true });
}
});

it('blocks symlink escaping workspace (write)', async () => {
const outsideDir = mkdtempSync(path.join(tmpdir(), 'pi-outside-'));
const linkPath = path.join(testDir, 'write-escape-link');
symlinkSync(outsideDir, linkPath);

try {
const writeTool = tools.find((t) => t.schema.name === 'write')!;
const result = await writeTool.execute({
path: 'write-escape-link/pwned.txt',
content: 'SHOULD NOT BE WRITTEN',
});
expect(result).toContain('Access denied');
expect(existsSync(path.join(outsideDir, 'pwned.txt'))).toBe(false);
} finally {
await rm(linkPath, { force: true });
await rm(outsideDir, { recursive: true, force: true });
}
});

it('can be disabled', async () => {
const unenforced = await createInkCodingTools({
cwd: testDir,
Expand Down
Loading
Loading