privateer-agent 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -5
- package/bin/privateer-acp.mjs +40 -0
- package/bin/privateer-launch.mjs +13 -0
- package/extensions/privateer-gate.ts +17 -1
- package/package.json +2 -1
- package/src/acp/protocol.ts +145 -0
- package/src/acp/run.ts +376 -0
- package/src/acp/server.ts +0 -0
- package/src/channels/bridge.ts +170 -24
- package/src/channels/platforms.ts +64 -0
- package/src/channels/run.ts +24 -7
- package/src/channels/types.ts +84 -13
- package/src/channels/whatsapp.ts +40 -4
- package/src/cli/chat.ts +3 -2
- package/src/config/inlineMoat.ts +67 -0
- package/src/ext/headlessUi.ts +116 -0
- package/src/harbor/buildLock.ts +19 -0
- package/src/harbor/index.ts +9 -12
- package/src/nostr/bech32.ts +117 -0
- package/src/nostr/event.ts +92 -0
- package/src/nostr/keys.ts +172 -0
- package/src/nostr/tags.ts +60 -0
- package/src/permissions/classify.ts +52 -1
- package/src/remote/channelsControl.ts +8 -16
- package/src/remote/liveTaskSession.ts +16 -3
- package/src/remote/relayClient.ts +87 -12
package/README.md
CHANGED
|
@@ -116,8 +116,8 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
116
116
|
- **Chat-app channels.** Bridge the agent into Telegram, Slack, Discord, or WhatsApp with
|
|
117
117
|
role-based approval — admins can approve actions, members are read-only.
|
|
118
118
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
119
|
-
or remote HTTP with OAuth), delegate work to bounded
|
|
120
|
-
skills — all gated like everything else.
|
|
119
|
+
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
120
|
+
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
121
121
|
- **Zero-Data-Retention surfacing** for OpenRouter — see the selected model's retention
|
|
122
122
|
posture before you send, and pin routing to zero-retention endpoints.
|
|
123
123
|
- **Plan mode**, checkpoint/rewind, session branching, a modal prompt with `/` command and
|
|
@@ -376,6 +376,80 @@ the app are write-only — the app can name them but never read them back. Note
|
|
|
376
376
|
live in plaintext in `config.json` on your machine, and every channel action is appended to
|
|
377
377
|
`~/.privateer/channels-audit.log`.
|
|
378
378
|
|
|
379
|
+
## Connectors — MCP
|
|
380
|
+
|
|
381
|
+
Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
382
|
+
server and that server's tools become first-class agent tools, gated exactly like the
|
|
383
|
+
built-ins. Two kinds:
|
|
384
|
+
|
|
385
|
+
| | |
|
|
386
|
+
|---|---|
|
|
387
|
+
| **Local — `stdio`** | Privateer spawns the server as a child process on this machine. Nothing leaves the box except what that server itself chooses to send. |
|
|
388
|
+
| **Remote — `http`** | An `https` endpoint somebody else hosts. It authenticates with `oauth` (you authorize in a browser on **this** machine), a static `bearer` token, or nothing at all. Whatever the agent hands that server leaves your machine — the app shows a "sends data to *host*" badge for exactly this reason. |
|
|
389
|
+
|
|
390
|
+
### Add one
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
/connect # add, enable, disable, or remove connectors
|
|
394
|
+
/mcp # pi-mcp-adapter's own status view — what actually connected
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`/connect` opens a picker over a curated catalog of 21 connectors — GitHub, Slack, Notion,
|
|
398
|
+
Linear, Jira & Confluence, Sentry, Stripe, Asana, Supabase, Figma, Gmail, Google Drive,
|
|
399
|
+
PostgreSQL, Playwright, Filesystem, … — plus a **Custom connector** entry for anything
|
|
400
|
+
else: any stdio command line, or any `https://` URL. Pick one, fill in the token or path it
|
|
401
|
+
asks for, and the adapter reloads in place, so the new tools are live in the session you're
|
|
402
|
+
already sitting in. You can do the same from [the app](#the-privateer-app) or the desktop
|
|
403
|
+
app; all three edit the same files.
|
|
404
|
+
|
|
405
|
+
### One config, three surfaces
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
~/.privateer/agent/mcp-desktop.json # source of truth — every connector, each with `enabled`
|
|
409
|
+
~/.privateer/agent/mcp.json # projection: enabled connectors only, in the standard
|
|
410
|
+
# { "mcpServers": … } shape the adapter reads
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Don't hand-edit the projection — it is rewritten from the source on every change. Edit
|
|
414
|
+
`mcp-desktop.json`, or just use `/connect`.
|
|
415
|
+
|
|
416
|
+
Both files live in the shared `~/.privateer` home, so a connector you add in the terminal is
|
|
417
|
+
already there for the harbor's unattended routine runs and for the desktop app's windows.
|
|
418
|
+
One machine, one coherent connector config, however you reached it.
|
|
419
|
+
|
|
420
|
+
### Tools and the moat
|
|
421
|
+
|
|
422
|
+
By default the adapter exposes MCP through a **single proxy tool named `mcp`** — one grant
|
|
423
|
+
covers every server you've enabled. When a routine carries a per-connector allow-list,
|
|
424
|
+
Privateer scopes that run down to exactly the selected servers and tools instead, each
|
|
425
|
+
registered under its own `<server>_<tool>` name, so an unattended task can hold GitHub's
|
|
426
|
+
`create_issue` without holding all of MCP.
|
|
427
|
+
|
|
428
|
+
Either way, **every MCP tool goes through the same permission gate as the built-ins.** A
|
|
429
|
+
tool is not trusted because you configured the server it came from.
|
|
430
|
+
|
|
431
|
+
### Credentials
|
|
432
|
+
|
|
433
|
+
A connector's secrets — env values, a bearer token, an `Authorization:` header — are written
|
|
434
|
+
in **plaintext** to `mcp-desktop.json` on this machine. That's unavoidable: the adapter has
|
|
435
|
+
to hand the real token to the server. `/connect` masks the field while you type, which is
|
|
436
|
+
shoulder-surfing and screen-share hygiene, not a storage claim. Protect that file the way you
|
|
437
|
+
protect `~/.aws/credentials`.
|
|
438
|
+
|
|
439
|
+
Editing connectors **from the app** is a different story: over the relay secrets are
|
|
440
|
+
write-only in both directions. A listing returns env/header *names* and which of them are
|
|
441
|
+
set — never a value — and a value you type on your phone is sealed to that terminal's pinned
|
|
442
|
+
key before it leaves the device, so the relay forwards it blind. See
|
|
443
|
+
[What you can do from the app](#what-you-can-do-from-the-app).
|
|
444
|
+
|
|
445
|
+
A hand-written `.mcp.json` in a project directory is a **protected path**: the agent can be
|
|
446
|
+
asked to edit one, but never does it silently, in any mode.
|
|
447
|
+
|
|
448
|
+
> **Hosted harbors are OAuth-only by design** — no stdio child processes, no stored tokens,
|
|
449
|
+
> because a hosted tenant's home is tmpfs and a durable secret would have to rest somewhere
|
|
450
|
+
> we could read. In the current preview they carry no connectors at all; mirroring your
|
|
451
|
+
> catalog into a hosted agent isn't wired up yet.
|
|
452
|
+
|
|
379
453
|
## Permission modes
|
|
380
454
|
|
|
381
455
|
| Mode | Behavior |
|
|
@@ -424,9 +498,8 @@ Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#b
|
|
|
424
498
|
drop your own into `~/.privateer/agent/extensions/` and it loads the same way, gated like the rest.
|
|
425
499
|
|
|
426
500
|
- **MCP servers** (`pi-mcp-adapter`) — declare them and their tools become first-class, gated
|
|
427
|
-
like the rest (local stdio, or remote HTTP with interactive OAuth).
|
|
428
|
-
|
|
429
|
-
app, so a machine has one coherent connector config.
|
|
501
|
+
like the rest (local stdio, or remote HTTP with interactive OAuth). Add them with
|
|
502
|
+
`/connect`; see [Connectors — MCP](#connectors--mcp).
|
|
430
503
|
- **Sub-agents** (`pi-subagents`) — delegate investigations to bounded parallel agents. Children
|
|
431
504
|
run as headless child processes that **inherit the moat**, so their actions hit the same
|
|
432
505
|
permission gate and their approvals surface on your phone.
|
|
@@ -447,6 +520,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
447
520
|
| `/verify` | fetch and check the TEE attestation for the current model |
|
|
448
521
|
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
449
522
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
523
|
+
| `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
|
|
450
524
|
| `/extensions` | list loaded Pi extensions |
|
|
451
525
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
452
526
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for Privateer's ACP surface — the entry an ACP host (Buzz's `buzz-acp`,
|
|
3
|
+
// Zed, …) spawns and drives over newline-delimited JSON-RPC on stdio.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors bin/privateer-harbor.mjs: load dev keys from the repo .env WITHOUT changing
|
|
6
|
+
// cwd, register tsx so TS resolves regardless of the invocation cwd, then hand off to
|
|
7
|
+
// src/acp/run.ts (which imports ./boot.ts before any Pi code).
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ THE SPAWN CWD IS THE CONFINEMENT ROOT unless `acp.cwd` is set in
|
|
10
|
+
// ~/.privateer/config.json. Whatever directory the host launches this process in is
|
|
11
|
+
// what the permission gate confines tools to — the per-session cwd a host sends in
|
|
12
|
+
// `session/new` does NOT override it (see the header of src/acp/run.ts). A host that
|
|
13
|
+
// spawns from `/` or `$HOME` therefore makes confinement very broad. Set `acp.cwd`
|
|
14
|
+
// when the host's spawn directory isn't the project you mean to scope the agent to.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ STDOUT IS THE PROTOCOL. Nothing in this process may write non-JSON-RPC bytes to
|
|
17
|
+
// stdout — a single stray line breaks the stream and the host disconnects. Note that
|
|
18
|
+
// process.loadEnvFile and tsx's register() are both silent; keep it that way, and
|
|
19
|
+
// send any diagnostic you add to stderr.
|
|
20
|
+
import { register } from "tsx/esm/api";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { dirname, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const repo = resolve(here, "..");
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
process.loadEnvFile(resolve(repo, ".env"));
|
|
29
|
+
} catch {
|
|
30
|
+
/* no .env — rely on the ambient environment / ~/.privateer */
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Same reasoning as the harbor: this process loads the moat as in-code factories, so
|
|
34
|
+
// subagent children can't inherit it and `pi` isn't on PATH. Point pi-subagents at
|
|
35
|
+
// our moat-injecting wrapper so any child spawns gated + private with no double-load.
|
|
36
|
+
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
37
|
+
|
|
38
|
+
register();
|
|
39
|
+
const { runAcp } = await import(resolve(repo, "src/acp/run.ts"));
|
|
40
|
+
await runAcp();
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -122,6 +122,19 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
122
122
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// --- `privateer acp` -------------------------------------------------------
|
|
126
|
+
// Privateer as an Agent Client Protocol server, spawned by an ACP host (Buzz's
|
|
127
|
+
// `buzz-acp`, Zed, …) and driven over newline-delimited JSON-RPC on stdio.
|
|
128
|
+
//
|
|
129
|
+
// ⚠️ STDOUT IS THE PROTOCOL here, so this branch must stay silent: no banner, no
|
|
130
|
+
// patch chatter, no moat-shim install (like `harbor`, the ACP entry loads the moat
|
|
131
|
+
// as in-code factories rather than discovered extensions). A single stray stdout
|
|
132
|
+
// line breaks the JSON-RPC stream and the host disconnects.
|
|
133
|
+
else if (sub === "acp") {
|
|
134
|
+
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
135
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)]);
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
// --- normal launch: install the moat, then exec Pi's TUI -------------------
|
|
126
139
|
else {
|
|
127
140
|
// Windows has no bash out of the box, but Privateer's command tool needs one. If a
|
|
@@ -27,6 +27,7 @@ import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
|
27
27
|
import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
29
|
import { inHarborDaemon } from "../src/config/harborDaemon.ts";
|
|
30
|
+
import { discoveredGateApplies } from "../src/config/inlineMoat.ts";
|
|
30
31
|
import { agentVersion } from "../src/config/version.ts";
|
|
31
32
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
32
33
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
@@ -434,7 +435,22 @@ const gate = makePermissionGate({
|
|
|
434
435
|
|
|
435
436
|
export default function privateerControl(pi: any): void {
|
|
436
437
|
piRef = pi;
|
|
437
|
-
|
|
438
|
+
// The moat — tool_call (block/allow) + tool_result (redact) — but ONLY when this
|
|
439
|
+
// session doesn't already carry one. A process that builds its sessions with
|
|
440
|
+
// makePermissionGate() as an inline factory (the harbor and its live task spawns,
|
|
441
|
+
// the channels runner) still auto-discovers this shim from the shared agent dir,
|
|
442
|
+
// and Pi runs EVERY tool_call handler — stopping early only on a block. Installing
|
|
443
|
+
// here too would mean two gates on one call: a second, redundant approval dialog
|
|
444
|
+
// where a UI is bound (a live task spawn), and a fail-closed local deny where one
|
|
445
|
+
// isn't (this file's bridge has no relay outside `/remote-access`). The session's
|
|
446
|
+
// own gate is the right one — it knows that session's cwd and its approver. See
|
|
447
|
+
// config/inlineMoat.ts for the full failure shape.
|
|
448
|
+
//
|
|
449
|
+
// A subagent child is the exception: it loads this file EXPLICITLY (`-e`, see
|
|
450
|
+
// bin/privateer-subagent.mjs) as its only gate, and inherits the parent's env — so
|
|
451
|
+
// it must keep installing regardless of the inherited marker, or children of a
|
|
452
|
+
// harbor task would run entirely ungated.
|
|
453
|
+
if (discoveredGateApplies(isSubagentChild())) gate(pi);
|
|
438
454
|
|
|
439
455
|
// Top-level session: watch the subagent approval channel and relay each child's
|
|
440
456
|
// gated action to the app over this session's bridge. The bridge fails closed while
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"@noble/curves": "^1.9.7",
|
|
69
69
|
"@noble/hashes": "^1.7.1",
|
|
70
70
|
"@phala/dcap-qvl": "^0.5.2",
|
|
71
|
+
"@zed-industries/agent-client-protocol": "^0.4.5",
|
|
71
72
|
"patch-package": "^8.0.1",
|
|
72
73
|
"pi-mcp-adapter": "^2.11.0",
|
|
73
74
|
"pi-privacy": "^0.9.0",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Pure ACP <-> Privateer mappings.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is a total function over plain data: no connection, no Pi, no
|
|
4
|
+
// clock. That's what makes tests/acp.test.ts able to pin the wire contract without
|
|
5
|
+
// spawning an agent or a relay — the same split that keeps `messageFromDiscord`
|
|
6
|
+
// testable apart from the Discord socket.
|
|
7
|
+
//
|
|
8
|
+
// The shapes come from @zed-industries/agent-client-protocol (the protocol authors'
|
|
9
|
+
// own package), NOT from prose docs. That distinction already mattered twice:
|
|
10
|
+
// the published docs claimed `stopReason: "Completed"` and a `channel_id` tag, and
|
|
11
|
+
// both were wrong. Types win; prose loses.
|
|
12
|
+
|
|
13
|
+
import type { ContentBlock, PermissionOption, RequestPermissionResponse } from "@zed-industries/agent-client-protocol";
|
|
14
|
+
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
15
|
+
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
16
|
+
import { isDangerousCommand, DEFAULT_DENYLIST } from "../permissions/danger.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Flatten ACP content blocks into the single string Pi's `session.prompt` takes.
|
|
20
|
+
*
|
|
21
|
+
* We advertise only the block kinds we can faithfully represent (see
|
|
22
|
+
* PROMPT_CAPABILITIES). Anything richer that arrives anyway is rendered as a
|
|
23
|
+
* labelled placeholder rather than dropped — silently discarding part of a user's
|
|
24
|
+
* message is worse than telling the model something was attached.
|
|
25
|
+
*/
|
|
26
|
+
export function promptText(blocks: ContentBlock[]): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
for (const b of blocks ?? []) {
|
|
29
|
+
switch (b.type) {
|
|
30
|
+
case "text":
|
|
31
|
+
parts.push(b.text);
|
|
32
|
+
break;
|
|
33
|
+
case "resource_link":
|
|
34
|
+
// A pointer to something the agent can open with its own tools.
|
|
35
|
+
parts.push(`[resource: ${b.name ?? b.uri}](${b.uri})`);
|
|
36
|
+
break;
|
|
37
|
+
case "resource": {
|
|
38
|
+
// Embedded context — the client inlined the bytes so we need no round-trip.
|
|
39
|
+
const r: any = b.resource;
|
|
40
|
+
if (typeof r?.text === "string") {
|
|
41
|
+
parts.push(r.uri ? `<resource uri="${r.uri}">\n${r.text}\n</resource>` : r.text);
|
|
42
|
+
} else if (r?.uri) {
|
|
43
|
+
parts.push(`[resource: ${r.uri}]`);
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "image":
|
|
48
|
+
parts.push(`[image attached: ${(b as any).mimeType ?? "image"}]`);
|
|
49
|
+
break;
|
|
50
|
+
case "audio":
|
|
51
|
+
parts.push(`[audio attached: ${(b as any).mimeType ?? "audio"}]`);
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
parts.push(`[unsupported content block: ${(b as any)?.type ?? "unknown"}]`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return parts.join("\n\n").trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* May this decision be remembered for the rest of the session?
|
|
62
|
+
*
|
|
63
|
+
* The gate has TWO destructive classes and only one of them is visible on the
|
|
64
|
+
* request. `alwaysAsk`/`protected` are fields; **dangerous shell** (`rm -rf`,
|
|
65
|
+
* `curl … | sh`, `dd of=/dev/…`, force-push to main, secret-exfil shapes) is
|
|
66
|
+
* computed inside `decideAuto` and never stamped back onto the request. Checking
|
|
67
|
+
* only the fields therefore let a dangerous command be cached, which is precisely
|
|
68
|
+
* what ModeGate refuses to do locally:
|
|
69
|
+
*
|
|
70
|
+
* "A dangerous command … can be approved once, but is never remembered: adding
|
|
71
|
+
* it to the allowlist or relaxing the mode would let a later variant slip
|
|
72
|
+
* through." — permissions/modeGate.ts
|
|
73
|
+
*
|
|
74
|
+
* On the ACP path `getRemote()` is always true, so ModeGate's remote branch returns
|
|
75
|
+
* before that check ever runs — this predicate is the only thing enforcing it. Used
|
|
76
|
+
* by BOTH the option list and the cache write so the two can't drift apart.
|
|
77
|
+
*
|
|
78
|
+
* `DEFAULT_DENYLIST` is the right default here: src/acp/run.ts sets no
|
|
79
|
+
* `ctrl.denylist`, and permissionGate.ts falls back to exactly this list.
|
|
80
|
+
*/
|
|
81
|
+
export function canRemember(req: PermissionRequest, denylist: string[] = DEFAULT_DENYLIST): boolean {
|
|
82
|
+
if (req.alwaysAsk || req.protected) return false;
|
|
83
|
+
if (req.kind === "bash" && isDangerousCommand(req.detail ?? "", denylist)) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The choices we offer a human for one gated action.
|
|
89
|
+
*
|
|
90
|
+
* "always" is withheld for anything `canRemember` rejects — a destructive action, a
|
|
91
|
+
* guarded file, or a dangerous shell command must never become standing permission,
|
|
92
|
+
* which is the same rule the TUI and the relay enforce. Offering the option and then
|
|
93
|
+
* ignoring it would be worse than not offering it.
|
|
94
|
+
*/
|
|
95
|
+
export function permissionOptions(req: PermissionRequest): PermissionOption[] {
|
|
96
|
+
const sticky = canRemember(req);
|
|
97
|
+
const options: PermissionOption[] = [{ optionId: "allow", name: "Allow", kind: "allow_once" }];
|
|
98
|
+
if (sticky) options.push({ optionId: "always", name: "Allow for the rest of this session", kind: "allow_always" });
|
|
99
|
+
options.push({ optionId: "deny", name: "Deny", kind: "reject_once" });
|
|
100
|
+
return options;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Map the client's answer back to a gate outcome. FAIL-CLOSED: anything we don't
|
|
105
|
+
* recognize — a cancelled prompt, an option id we never offered, a malformed
|
|
106
|
+
* response — is a denial. An ambiguous answer must never widen permission.
|
|
107
|
+
*/
|
|
108
|
+
export function outcomeToAsk(res: RequestPermissionResponse | undefined): AskOutcome {
|
|
109
|
+
const outcome = res?.outcome;
|
|
110
|
+
if (!outcome || outcome.outcome !== "selected") return "deny";
|
|
111
|
+
switch (outcome.optionId) {
|
|
112
|
+
case "allow":
|
|
113
|
+
return "allow";
|
|
114
|
+
case "always":
|
|
115
|
+
return "always";
|
|
116
|
+
default:
|
|
117
|
+
return "deny";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A one-line human summary of a gated action, for the client's permission UI. */
|
|
122
|
+
export function permissionTitle(req: PermissionRequest): string {
|
|
123
|
+
const detail = req.detail?.trim() ?? "";
|
|
124
|
+
const firstLine = detail.split("\n")[0]?.slice(0, 120) ?? "";
|
|
125
|
+
return firstLine ? `${req.title} — ${firstLine}` : req.title;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Privateer's permission kinds, in ACP's vocabulary. Exhaustive over PermissionKind
|
|
129
|
+
* ("write" | "edit" | "bash" | "fetch" | "read"), so adding a kind is a type error
|
|
130
|
+
* here rather than a silent "other" in someone's approval dialog. */
|
|
131
|
+
export function toolKindFor(req: PermissionRequest): "read" | "edit" | "execute" | "fetch" | "other" {
|
|
132
|
+
switch (req.kind) {
|
|
133
|
+
case "read":
|
|
134
|
+
return "read";
|
|
135
|
+
case "write":
|
|
136
|
+
case "edit":
|
|
137
|
+
return "edit";
|
|
138
|
+
case "bash":
|
|
139
|
+
return "execute";
|
|
140
|
+
case "fetch":
|
|
141
|
+
return "fetch";
|
|
142
|
+
default:
|
|
143
|
+
return "other";
|
|
144
|
+
}
|
|
145
|
+
}
|