privateer-agent 0.12.51 → 0.12.52
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/extensions/privateer-models.ts +60 -1
- package/extensions/privateer-web.ts +8 -0
- package/package.json +1 -1
- package/src/acp/run.ts +3 -1
- package/src/channels/run.ts +3 -1
- package/src/config/moat.ts +5 -0
- package/src/config/moatManifest.json +1 -1
- package/src/ext/permissionGate.ts +7 -1
- package/src/harbor/index.ts +9 -7
- package/src/permissions/classify.ts +18 -0
- package/src/remote/controlAuth.ts +22 -1
- package/src/remote/relayClient.ts +65 -5
- package/src/tools/crypto.ts +101 -0
- package/src/tools/web.ts +3 -3
- package/src/util/coauthor.ts +119 -0
|
@@ -21,7 +21,17 @@ import {
|
|
|
21
21
|
Input,
|
|
22
22
|
Spacer,
|
|
23
23
|
Text,
|
|
24
|
+
type Component,
|
|
25
|
+
type TuiMouseEvent,
|
|
26
|
+
type TuiMouseEventResult,
|
|
24
27
|
} from "@earendil-works/pi-tui";
|
|
28
|
+
|
|
29
|
+
// TuiMouseDispatchResult isn't re-exported from the package index — same shape,
|
|
30
|
+
// typed locally so the Container.handleMouse override matches its signature.
|
|
31
|
+
interface MouseDispatchResult extends TuiMouseEventResult {
|
|
32
|
+
handled: true;
|
|
33
|
+
target: { component: Component; originX: number; originY: number; width: number; height: number };
|
|
34
|
+
}
|
|
25
35
|
import { TIERS, tierRank, type PrivacyTier } from "pi-privacy";
|
|
26
36
|
import { verifyModelPosture } from "pi-privacy";
|
|
27
37
|
import {
|
|
@@ -148,6 +158,10 @@ function sortRows(rows: Row[]): Row[] {
|
|
|
148
158
|
|
|
149
159
|
const MAX_VISIBLE = 12;
|
|
150
160
|
|
|
161
|
+
// Lines the picker renders above the first model row (title, legend, blank,
|
|
162
|
+
// search input, blank). Used to translate a click's local y into a row index.
|
|
163
|
+
const HEADER_LINES = 5;
|
|
164
|
+
|
|
151
165
|
class ModelsPicker extends Container {
|
|
152
166
|
private tui: TuiLike;
|
|
153
167
|
private theme: ThemeLike;
|
|
@@ -202,7 +216,7 @@ class ModelsPicker extends Container {
|
|
|
202
216
|
this.addChild(this.blurbText);
|
|
203
217
|
this.addChild(
|
|
204
218
|
new Text(
|
|
205
|
-
t.fg("muted", "↑↓ navigate
|
|
219
|
+
t.fg("muted", "↑↓ navigate PgUp/PgDn page wheel scroll click row ⏎ select esc cancel"),
|
|
206
220
|
1,
|
|
207
221
|
0,
|
|
208
222
|
),
|
|
@@ -317,6 +331,47 @@ class ModelsPicker extends Container {
|
|
|
317
331
|
this.applyFilter(q);
|
|
318
332
|
}
|
|
319
333
|
|
|
334
|
+
// Page the highlight by a whole viewport, clamped — PgUp/PgDn and shift-free
|
|
335
|
+
// long hops so a long ranked list is actually traversable.
|
|
336
|
+
private page(delta: number): void {
|
|
337
|
+
if (!this.filtered.length) return;
|
|
338
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex + delta, this.filtered.length - 1));
|
|
339
|
+
this.updateList();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Mouse support: wheel scrolls the highlight through the list, click jumps to a
|
|
343
|
+
// visible row. The terminal reports wheel as logical lines (negative = up).
|
|
344
|
+
handleMouse(event: TuiMouseEvent): MouseDispatchResult | undefined {
|
|
345
|
+
const handledHere = (): MouseDispatchResult => ({
|
|
346
|
+
handled: true,
|
|
347
|
+
target: { component: this, originX: 0, originY: 0, width: event.width, height: event.height },
|
|
348
|
+
});
|
|
349
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
350
|
+
const delta = -event.wheelDelta;
|
|
351
|
+
if (!this.filtered.length || delta === 0) return handledHere();
|
|
352
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex + delta, this.filtered.length - 1));
|
|
353
|
+
this.updateList();
|
|
354
|
+
this.tui.requestRender();
|
|
355
|
+
return handledHere();
|
|
356
|
+
}
|
|
357
|
+
if (event.type === "click" || event.type === "press") {
|
|
358
|
+
const row = event.y - HEADER_LINES;
|
|
359
|
+
if (row < 0 || row >= MAX_VISIBLE) return undefined;
|
|
360
|
+
const start = Math.max(
|
|
361
|
+
0,
|
|
362
|
+
Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
|
|
363
|
+
);
|
|
364
|
+
const idx = start + row;
|
|
365
|
+
if (idx >= 0 && idx < this.filtered.length) {
|
|
366
|
+
this.selectedIndex = idx;
|
|
367
|
+
this.updateList();
|
|
368
|
+
this.tui.requestRender();
|
|
369
|
+
return handledHere();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
320
375
|
handleInput(keyData: string): void {
|
|
321
376
|
const kb = getKeybindings();
|
|
322
377
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
@@ -329,6 +384,10 @@ class ModelsPicker extends Container {
|
|
|
329
384
|
this.selectedIndex =
|
|
330
385
|
this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
|
|
331
386
|
this.updateList();
|
|
387
|
+
} else if (kb.matches(keyData, "tui.select.pageUp")) {
|
|
388
|
+
this.page(-MAX_VISIBLE);
|
|
389
|
+
} else if (kb.matches(keyData, "tui.select.pageDown")) {
|
|
390
|
+
this.page(MAX_VISIBLE);
|
|
332
391
|
} else if (kb.matches(keyData, "tui.select.confirm")) {
|
|
333
392
|
const row = this.filtered[this.selectedIndex];
|
|
334
393
|
if (row) this.onSelect(row);
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// pulling that package's own .ts sources into our typecheck, the same trick config/moat.ts
|
|
36
36
|
// uses for pi-mcp-adapter.
|
|
37
37
|
import { guardedWebToolDefinitions } from "../src/tools/web.ts";
|
|
38
|
+
import { guardedCryptoToolDefinitions } from "../src/tools/crypto.ts";
|
|
38
39
|
import { readWebToolsConfig, resolveWebMode, type ProviderMetaLike } from "../src/tools/webMode.ts";
|
|
39
40
|
|
|
40
41
|
const RPIV_WEB_TOOLS = "@juicesharp/rpiv-web-tools";
|
|
@@ -47,6 +48,9 @@ const SIGNED_OUT_HINT =
|
|
|
47
48
|
"allowance, or /web-tools to use your own search provider (a Brave/Tavily key, or a " +
|
|
48
49
|
"self-hosted SearXNG) instead.";
|
|
49
50
|
|
|
51
|
+
const CRYPTO_SIGNED_OUT_HINT =
|
|
52
|
+
"Crypto lookups need a Privateer account: run /signin to use market data on your account.";
|
|
53
|
+
|
|
50
54
|
interface RpivWebTools {
|
|
51
55
|
registerWebSearchTool(pi: unknown): void;
|
|
52
56
|
registerWebFetchTool(pi: unknown): void;
|
|
@@ -90,6 +94,10 @@ export default async function privateerWeb(pi: any): Promise<void> {
|
|
|
90
94
|
for (const def of guardedWebToolDefinitions(SIGNED_OUT_HINT)) pi.registerTool?.(def);
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
// Market data in BOTH branches: no user-configured provider serves it, so picking
|
|
98
|
+
// your own search engine must not cost you crypto_lookup. See tools/crypto.ts.
|
|
99
|
+
for (const def of guardedCryptoToolDefinitions(CRYPTO_SIGNED_OUT_HINT)) pi.registerTool?.(def);
|
|
100
|
+
|
|
93
101
|
// Always available, whichever pack won: it is how a user on the account route configures
|
|
94
102
|
// a provider of their own, and the only place to see which keys are resolving.
|
|
95
103
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.52",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/acp/run.ts
CHANGED
|
@@ -43,13 +43,15 @@
|
|
|
43
43
|
|
|
44
44
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
45
45
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
46
|
+
import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
|
|
46
47
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
47
48
|
|
|
48
49
|
// Read-only by default, exactly as the channels runtime and the routines harbor do:
|
|
49
50
|
// a turn nobody is watching must not be able to mutate the filesystem or shell out
|
|
50
51
|
// until a human widens it in config.
|
|
51
52
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
52
|
-
|
|
53
|
+
// crypto_lookup rides the web switch: its tickers/address leave the run as a query does.
|
|
54
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES, ...CRYPTO_TOOL_NAMES];
|
|
53
55
|
// Media generation stays out of the default set (it spends the account's credit —
|
|
54
56
|
// see the harbor's note); an ACP host that wants it names it in `acp.tools`. Listed
|
|
55
57
|
// here so it can be stripped back out when generation is switched off.
|
package/src/channels/run.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
51
51
|
// Pi-free static graph by design — see the header of piAuthStore.ts.
|
|
52
52
|
import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
53
53
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
54
|
+
import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
|
|
54
55
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
55
56
|
|
|
56
57
|
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
@@ -63,7 +64,8 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
|
63
64
|
// Web tools join the default set when the agent has web access — see
|
|
64
65
|
// config/hosted.ts. Kept out of SAFE_TOOLS proper because they're the one "read-only"
|
|
65
66
|
// capability that still sends a query off the machine.
|
|
66
|
-
|
|
67
|
+
// crypto_lookup rides the web switch: its tickers/address leave the run as a query does.
|
|
68
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES, ...CRYPTO_TOOL_NAMES];
|
|
67
69
|
|
|
68
70
|
// Media GENERATION never joins the default set, even with the switch on: a chat
|
|
69
71
|
// message is an untrusted prompt, and these spend the account's credit. A channel that
|
package/src/config/moat.ts
CHANGED
|
@@ -312,12 +312,17 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
312
312
|
if (caps.web === "account" && webEnabled()) {
|
|
313
313
|
const { makeWebTools } = await import("../tools/web.ts");
|
|
314
314
|
factories.push(makeWebTools());
|
|
315
|
+
// Market data rides the web switch: the tickers leave exactly as a query does.
|
|
316
|
+
const { makeCryptoTools } = await import("../tools/crypto.ts");
|
|
317
|
+
factories.push(makeCryptoTools());
|
|
315
318
|
} else if (caps.web === "guarded") {
|
|
316
319
|
if (!opts.webHint) throw new Error(`buildMoat: kind "${opts.kind}" needs a webHint for guarded web tools`);
|
|
317
320
|
const hint = opts.webHint;
|
|
318
321
|
const { guardedWebToolDefinitions } = await import("../tools/web.ts");
|
|
322
|
+
const { guardedCryptoToolDefinitions } = await import("../tools/crypto.ts");
|
|
319
323
|
factories.push((pi: any) => {
|
|
320
324
|
for (const def of guardedWebToolDefinitions(hint)) pi.registerTool?.(def);
|
|
325
|
+
for (const def of guardedCryptoToolDefinitions(hint)) pi.registerTool?.(def);
|
|
321
326
|
});
|
|
322
327
|
}
|
|
323
328
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
25
25
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
26
26
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
27
|
-
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools)" },
|
|
27
|
+
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
|
|
28
28
|
{ "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
|
|
29
29
|
{ "dep": ["pi-mcp-adapter", "index.ts"], "name": "pi-mcp-adapter", "note": "MCP servers as first-class tools" },
|
|
30
30
|
{ "dep": ["pi-subagents", "src", "extension", "index.ts"], "name": "pi-subagents", "note": "bounded parallel sub-agents" },
|
|
@@ -16,6 +16,7 @@ import type { PermissionRequest } from "../permissions/gate.ts";
|
|
|
16
16
|
import { ModeGate, type AskOutcome } from "../permissions/modeGate.ts";
|
|
17
17
|
import { classifyToolCall } from "../permissions/classify.ts";
|
|
18
18
|
import { redactText } from "../util/redact.ts";
|
|
19
|
+
import { coauthorGitCommits } from "../util/coauthor.ts";
|
|
19
20
|
import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
|
|
20
21
|
|
|
21
22
|
// The per-tool_call hook context we read (structural subset of Pi's ctx — kept
|
|
@@ -271,7 +272,12 @@ export function makePermissionGate(ctrl: GateController) {
|
|
|
271
272
|
}
|
|
272
273
|
|
|
273
274
|
return function permissionGate(pi: PiExtensionApi): void {
|
|
274
|
-
|
|
275
|
+
// Co-author the user's commits before the gate decides, so an approval prompt shows
|
|
276
|
+
// the command that will actually run (util/coauthor.ts).
|
|
277
|
+
pi.on("tool_call", (event, ctx) => {
|
|
278
|
+
coauthorGitCommits(event.toolName, event.input);
|
|
279
|
+
return decideToolCall(ctrl, event.toolName, event.input, ctx);
|
|
280
|
+
});
|
|
275
281
|
|
|
276
282
|
// Redact secrets from tool output before it reaches the model / relay.
|
|
277
283
|
// tool_result delivers content PARTS (per the Pi extension contract), not a
|
package/src/harbor/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
|
36
36
|
import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
37
37
|
import { verifyChannelSave } from "../crypto/accountVerify.ts";
|
|
38
38
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
39
|
-
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
39
|
+
import { authorizeControl, controlRequiresFreshTs } from "../remote/controlAuth.ts";
|
|
40
40
|
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
41
41
|
import {
|
|
42
42
|
loadRoutines,
|
|
@@ -67,6 +67,7 @@ import { serializeBuild } from "./buildLock.ts";
|
|
|
67
67
|
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
68
68
|
import { relayExposureAllowed } from "../config/relayExposure.ts";
|
|
69
69
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
70
|
+
import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
|
|
70
71
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
71
72
|
import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
|
|
72
73
|
import { BILLED_MEDIA_TOOLS } from "../permissions/classify.ts";
|
|
@@ -84,7 +85,8 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
|
84
85
|
// they join the DEFAULT allow-list: the overwhelmingly common unattended request
|
|
85
86
|
// ("summarize today's news at 7pm") needs the live web and nothing else, and making
|
|
86
87
|
// the user hand-write an allow-list for it was the whole friction.
|
|
87
|
-
|
|
88
|
+
// crypto_lookup rides the web switch: its tickers/address leave the run as a query does.
|
|
89
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES, ...CRYPTO_TOOL_NAMES];
|
|
88
90
|
|
|
89
91
|
// Media generation (src/tools/media.ts) plus local composition (videoCompose.ts).
|
|
90
92
|
//
|
|
@@ -579,11 +581,11 @@ export class Harbor {
|
|
|
579
581
|
ts: number | undefined,
|
|
580
582
|
run: () => string | undefined,
|
|
581
583
|
): string | undefined {
|
|
582
|
-
// task_submit/task_spawn are
|
|
583
|
-
// require a strictly-fresh ts — a replayed frame with an
|
|
584
|
-
//
|
|
585
|
-
// at-or-above acceptance. See
|
|
586
|
-
const strict = action
|
|
584
|
+
// Run-style actions (task_submit/task_spawn/routines_run/workflows_run) are
|
|
585
|
+
// NON-idempotent, so they require a strictly-fresh ts — a replayed frame with an
|
|
586
|
+
// equal ts must NOT run again. Only the listed idempotent config mutations keep
|
|
587
|
+
// at-or-above acceptance. See controlRequiresFreshTs / authorizeControl.
|
|
588
|
+
const strict = controlRequiresFreshTs(action);
|
|
587
589
|
const auth = authorizeControl(routineRelayId(), action, args, sig, ts, { strict });
|
|
588
590
|
if (!auth.ok) return auth.message;
|
|
589
591
|
return run();
|
|
@@ -630,6 +630,24 @@ export function classifyToolCall(
|
|
|
630
630
|
};
|
|
631
631
|
}
|
|
632
632
|
|
|
633
|
+
// crypto_lookup (src/tools/crypto.ts) — a network read through the account API,
|
|
634
|
+
// like web_search. Named explicitly because nothing in its name matches the
|
|
635
|
+
// heuristic below, and the unknown-tool branch would make it bash-kind: a JSON
|
|
636
|
+
// prompt, and an outright DENY in plan/readonly, where "what's SOL at?" is as
|
|
637
|
+
// reasonable a question as any search.
|
|
638
|
+
if (name === "crypto_lookup") {
|
|
639
|
+
const mode = str(obj.mode) || "tokens";
|
|
640
|
+
const what = mode === "portfolio"
|
|
641
|
+
? (str(obj.address) ? `wallet ${str(obj.address)}` : "the account's own Solana wallet")
|
|
642
|
+
: mode === "tokens" ? str(obj.query) : `${mode} tokens${obj.chain ? ` on ${str(obj.chain)}` : ""}`;
|
|
643
|
+
return {
|
|
644
|
+
tool: toolName,
|
|
645
|
+
kind: "fetch",
|
|
646
|
+
title: "Look up crypto market data",
|
|
647
|
+
detail: what,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
633
651
|
// Network reads (web fetch / search / http).
|
|
634
652
|
if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
|
|
635
653
|
return {
|
|
@@ -17,6 +17,27 @@
|
|
|
17
17
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
18
18
|
import { verifyControl } from "../crypto/accountVerify.ts";
|
|
19
19
|
|
|
20
|
+
// Control actions that are safe to re-apply: replaying the latest signed frame (same ts)
|
|
21
|
+
// just re-asserts the same config. Everything NOT listed here — routines_run,
|
|
22
|
+
// workflows_run, task_submit/task_spawn, and any action added later — is treated as
|
|
23
|
+
// effectful and verified in strict mode. Default-strict so a new "run" action can't
|
|
24
|
+
// silently inherit replayability (routines_run once did).
|
|
25
|
+
const IDEMPOTENT_CONTROL_ACTIONS: ReadonlySet<string> = new Set([
|
|
26
|
+
"routines_save",
|
|
27
|
+
"routines_delete",
|
|
28
|
+
"routines_set_enabled",
|
|
29
|
+
"channels_remove",
|
|
30
|
+
"mcp_set_enabled",
|
|
31
|
+
"mcp_remove",
|
|
32
|
+
"workflows_save",
|
|
33
|
+
"workflows_remove",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** True when `action` must carry a strictly-fresh ts (see authorizeControl's `strict`). */
|
|
37
|
+
export function controlRequiresFreshTs(action: string): boolean {
|
|
38
|
+
return !IDEMPOTENT_CONTROL_ACTIONS.has(action);
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export interface ControlAuthResult {
|
|
21
42
|
ok: boolean;
|
|
22
43
|
message?: string;
|
|
@@ -33,7 +54,7 @@ export interface ControlAuthResult {
|
|
|
33
54
|
* idempotent config mutations (routines/skills/extensions/channels save|delete),
|
|
34
55
|
* where replaying the latest signed frame just re-applies the same state — harmless.
|
|
35
56
|
* - strict: reject ts AT-or-below the watermark. Required for NON-idempotent, effectful
|
|
36
|
-
* actions (task_submit/task_spawn — each RUNS
|
|
57
|
+
* actions (task_submit/task_spawn/routines_run/workflows_run — each RUNS something), where a malicious
|
|
37
58
|
* relay replaying the latest signed frame (same ts) would re-run the task / spawn
|
|
38
59
|
* another session (inference-cost + resource abuse). Strict forces every accepted
|
|
39
60
|
* effectful frame to carry a strictly-fresh ts, which the server cannot fabricate (it
|
|
@@ -22,6 +22,8 @@ import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
|
|
|
22
22
|
import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
|
|
23
23
|
import type { EngineEvent } from "../engine/events.ts";
|
|
24
24
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
25
|
+
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
26
|
+
import { authorizeControl } from "./controlAuth.ts";
|
|
25
27
|
import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
|
|
26
28
|
import {
|
|
27
29
|
LIBRARY_CHUNK_CHARS,
|
|
@@ -120,11 +122,13 @@ export interface RelayCallbacks {
|
|
|
120
122
|
// relay. Optional so callbacks that predate the frame keep compiling; an older
|
|
121
123
|
// CLI that ignores it still gets signed out by the ≤25s heartbeat kill.
|
|
122
124
|
onRevoked?: () => void;
|
|
123
|
-
// The app answered a relayed approval request.
|
|
125
|
+
// The app answered a relayed approval request. An "allow" reaches here only once
|
|
126
|
+
// handle() has verified the account's signature over it; a deny needs none.
|
|
124
127
|
onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
|
|
125
128
|
// The app toggled no-quarter (unattended) mode: remote turns auto-approve like
|
|
126
129
|
// bypass mode instead of relaying every action, so the agent runs to completion.
|
|
127
130
|
// Dangerous/destructive actions still relay — they sit above bypass locally too.
|
|
131
|
+
// `on: true` reaches here only once handle() has verified the account's signature.
|
|
128
132
|
onNoQuarter?: (on: boolean) => void;
|
|
129
133
|
// The app toggled the privacy filter. `off` true = pi-privacy is disabled for this
|
|
130
134
|
// session, exactly as `/privacy off` does it: no outbound PII scan or prompt, no
|
|
@@ -765,12 +769,59 @@ export class RelayClient {
|
|
|
765
769
|
case "session_revoked":
|
|
766
770
|
this.cb.onRevoked?.();
|
|
767
771
|
break;
|
|
768
|
-
|
|
769
|
-
|
|
772
|
+
// The two frames that LOWER the permission gate. The relay is untrusted (docs/
|
|
773
|
+
// harbor-channels-and-app.md §6), and before this an unsigned `approval_response`
|
|
774
|
+
// or `no_quarter` from it was taken as the human's word — a hostile relay could
|
|
775
|
+
// allow any pending action, or lift the gate for the whole session. So each is
|
|
776
|
+
// account-signed now, like every other mutating control frame (H2), and verified
|
|
777
|
+
// against THIS terminal's id. Only the direction that widens what the agent may do
|
|
778
|
+
// needs proof: a deny, or no-quarter off, is safe from anyone and stays unsigned.
|
|
779
|
+
case "approval_response": {
|
|
780
|
+
if (typeof frame.id !== "string" || !frame.id) break;
|
|
781
|
+
// Exactly "allow" — anything else, including a missing or unknown decision,
|
|
782
|
+
// is a deny. This used to be the other way round.
|
|
783
|
+
if (frame.decision !== "allow") {
|
|
784
|
+
this.cb.onApprovalResponse(frame.id, "deny");
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
// Not strict: the id binds the signature to one request, and the bridge
|
|
788
|
+
// settles each id once, so a replay has nothing left to answer. Strict would
|
|
789
|
+
// refuse the burst the app sends when it allows a queue in the same ms.
|
|
790
|
+
const auth = authorizeControl(
|
|
791
|
+
this.termId,
|
|
792
|
+
"approval_response",
|
|
793
|
+
{ id: frame.id, decision: "allow" },
|
|
794
|
+
sig(frame),
|
|
795
|
+
tsOf(frame),
|
|
796
|
+
);
|
|
797
|
+
if (!auth.ok) {
|
|
798
|
+
this.refuseControl(auth.message);
|
|
799
|
+
// Settle it now rather than leave the gate waiting out its timeout.
|
|
800
|
+
this.cb.onApprovalResponse(frame.id, "deny");
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
this.cb.onApprovalResponse(frame.id, "allow");
|
|
770
804
|
break;
|
|
771
|
-
|
|
772
|
-
|
|
805
|
+
}
|
|
806
|
+
case "no_quarter": {
|
|
807
|
+
if (frame.on !== true) {
|
|
808
|
+
this.cb.onNoQuarter?.(false);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
// Strict: `on` is not bound to anything that expires, so a relay that kept the
|
|
812
|
+
// last signed `on` could otherwise replay it after the user turned the flag off.
|
|
813
|
+
const auth = authorizeControl(this.termId, "no_quarter", { on: true }, sig(frame), tsOf(frame), {
|
|
814
|
+
strict: true,
|
|
815
|
+
});
|
|
816
|
+
if (!auth.ok) {
|
|
817
|
+
this.refuseControl(auth.message);
|
|
818
|
+
// The app flips its toggle optimistically; tell it what actually holds.
|
|
819
|
+
this.sendNoQuarter(noQuarterActive());
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
this.cb.onNoQuarter?.(true);
|
|
773
823
|
break;
|
|
824
|
+
}
|
|
774
825
|
case "privacy":
|
|
775
826
|
this.cb.onPrivacy?.(frame.off === true);
|
|
776
827
|
break;
|
|
@@ -1206,6 +1257,15 @@ export class RelayClient {
|
|
|
1206
1257
|
this.rawSend(frame);
|
|
1207
1258
|
}
|
|
1208
1259
|
|
|
1260
|
+
// A signed control frame failed verification here. Say so on both ends: the app,
|
|
1261
|
+
// whose user pressed the button, and this terminal, whose user owns the machine.
|
|
1262
|
+
private refuseControl(message?: string): void {
|
|
1263
|
+
const text = message ?? "Refused an unverified change from the app.";
|
|
1264
|
+
this.debug(`refused control: ${text}`);
|
|
1265
|
+
this.sendNotice(text);
|
|
1266
|
+
this.cb.onStatus?.(text);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1209
1269
|
// A one-line notice for the app's feed (e.g. "model → …", "unknown command").
|
|
1210
1270
|
// The generic command-feedback channel back to the driver.
|
|
1211
1271
|
sendNotice(text: string): void {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `crypto_lookup` — live crypto market and Solana wallet data, served by Privateer.
|
|
2
|
+
//
|
|
3
|
+
// Same posture as web_search (see web.ts's header, which applies here word for word):
|
|
4
|
+
// the call goes to the account API (`/api/rag/crypto`) with the session credential
|
|
5
|
+
// this agent already holds, so no data-provider key ever sits in a harbor's
|
|
6
|
+
// environment. The server resolves the lookup — Solana tickers through Jupiter's
|
|
7
|
+
// verified token registry, market data from DexScreener, wallet holdings from the
|
|
8
|
+
// Solana RPC — and hands back a formatted block.
|
|
9
|
+
//
|
|
10
|
+
// WHAT LEAVES. The tickers, or the wallet address, reach Privateer's servers and the
|
|
11
|
+
// public data providers in plaintext; the run's prompt and result do not. That is the
|
|
12
|
+
// web_search trade exactly, which is why this tool rides the same "web access" switch
|
|
13
|
+
// on unattended paths (harbor/channels/ACP add it to their WEB_TOOLS allow-lists).
|
|
14
|
+
//
|
|
15
|
+
// "MY WALLET" IS THE ACCOUNT'S, NEVER A CLAIM. `portfolio` without an address reads the
|
|
16
|
+
// Solana wallet the account signed in with, and the server takes it from the account
|
|
17
|
+
// record, not from this request. A pasted address is public chain state.
|
|
18
|
+
//
|
|
19
|
+
// READ-ONLY. Nothing here signs, swaps or moves funds, and the result tells the model
|
|
20
|
+
// it is information, not financial advice.
|
|
21
|
+
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
24
|
+
import { callRag, text } from "./web.ts";
|
|
25
|
+
|
|
26
|
+
/** Tool names this module registers, for allow-list construction. */
|
|
27
|
+
export const CRYPTO_TOOL_NAMES = ["crypto_lookup"] as const;
|
|
28
|
+
|
|
29
|
+
const MODES = ["tokens", "portfolio", "new", "boosted"] as const;
|
|
30
|
+
type Mode = (typeof MODES)[number];
|
|
31
|
+
|
|
32
|
+
const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
33
|
+
|
|
34
|
+
export const cryptoLookupToolDefinition = {
|
|
35
|
+
name: "crypto_lookup",
|
|
36
|
+
label: "Crypto Lookup",
|
|
37
|
+
description:
|
|
38
|
+
"Live crypto market and Solana wallet data. Modes: " +
|
|
39
|
+
"`tokens` — price, 1h/24h change, volume, liquidity, market cap, holders and pair age for up to four " +
|
|
40
|
+
"tickers, names or contract addresses (Solana tickers are matched against the verified token registry; " +
|
|
41
|
+
"BTC/ETH/BNB are priced from their wrapped forms); " +
|
|
42
|
+
"`portfolio` — a Solana wallet's holdings priced and totalled (omit `address` for the signed-in account's " +
|
|
43
|
+
"own wallet); `new` — the newest token listings; `boosted` — tokens paying for promotion on DexScreener " +
|
|
44
|
+
"(paid placement, not organic popularity). Use this rather than web_search for prices and holdings; use " +
|
|
45
|
+
"web_search for crypto NEWS. Read-only. Runs through the user's Privateer account — the tickers or " +
|
|
46
|
+
"address are visible to Privateer's servers.",
|
|
47
|
+
parameters: Type.Object({
|
|
48
|
+
mode: Type.Optional(Type.Union(MODES.map((m) => Type.Literal(m)), { description: "What to look up. Defaults to tokens." })),
|
|
49
|
+
query: Type.Optional(Type.String({ description: "tokens mode: comma-separated tickers, names or contract addresses, e.g. \"SOL, BONK\"." })),
|
|
50
|
+
address: Type.Optional(Type.String({ description: "portfolio mode: a Solana wallet address. Omit for the account's own wallet." })),
|
|
51
|
+
chain: Type.Optional(Type.String({ description: "new/boosted: narrow to one chain, e.g. \"solana\", \"ethereum\", \"base\", \"bsc\"." })),
|
|
52
|
+
}),
|
|
53
|
+
async execute(_toolCallId: string, params: { mode?: Mode; query?: string; address?: string; chain?: string }) {
|
|
54
|
+
const mode: Mode = MODES.includes(params.mode as Mode) ? (params.mode as Mode) : "tokens";
|
|
55
|
+
const query = String(params.query ?? "").trim();
|
|
56
|
+
const address = String(params.address ?? "").trim();
|
|
57
|
+
if (mode === "tokens" && !query) return text("Error: query is required in tokens mode (e.g. \"SOL, BONK\").");
|
|
58
|
+
if (address && !SOLANA_ADDRESS.test(address)) return text("Error: address must be a Solana wallet address.");
|
|
59
|
+
|
|
60
|
+
const body: Record<string, unknown> = { mode, raw: true };
|
|
61
|
+
if (mode === "tokens") body.query = query;
|
|
62
|
+
if (mode === "portfolio" && address) body.address = address;
|
|
63
|
+
const chain = String(params.chain ?? "").trim().toLowerCase();
|
|
64
|
+
if ((mode === "new" || mode === "boosted") && chain) body.chain = chain;
|
|
65
|
+
|
|
66
|
+
const r = await callRag<{ found?: boolean; text?: string }>("/api/rag/crypto", body);
|
|
67
|
+
if (!r.ok) return text(`Crypto lookup failed: ${r.message}`);
|
|
68
|
+
if (!r.data.found || !r.data.text) {
|
|
69
|
+
return text(
|
|
70
|
+
mode === "tokens"
|
|
71
|
+
? `No DEX market found for ${query}. It may not trade on-chain, or the ticker may be spelled differently — try a contract address, or web_search.`
|
|
72
|
+
: "No market data came back for that lookup.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return text(r.data.text);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Extension factory for the unattended paths (see config/moat.ts), beside makeWebTools. */
|
|
80
|
+
export function makeCryptoTools() {
|
|
81
|
+
return (pi: { registerTool?: (def: unknown) => void }): void => {
|
|
82
|
+
pi.registerTool?.(cryptoLookupToolDefinition);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The interactive form: a sign-in check per call, for the same reason
|
|
88
|
+
* guardedWebToolDefinitions has one (a terminal signs in mid-session). Registered in
|
|
89
|
+
* BOTH of privateer-web.ts's branches — unlike web_search there is no user-configured
|
|
90
|
+
* provider for this to compete with, so choosing your own search engine must not cost
|
|
91
|
+
* you market data.
|
|
92
|
+
*/
|
|
93
|
+
export function guardedCryptoToolDefinitions(hint: string): unknown[] {
|
|
94
|
+
return [{
|
|
95
|
+
...cryptoLookupToolDefinition,
|
|
96
|
+
async execute(toolCallId: string, params: any) {
|
|
97
|
+
if (!hasCredentials()) return text(hint);
|
|
98
|
+
return cryptoLookupToolDefinition.execute(toolCallId, params);
|
|
99
|
+
},
|
|
100
|
+
}];
|
|
101
|
+
}
|
package/src/tools/web.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const WEB_TOOL_NAMES = ["web_search", "web_fetch"] as const;
|
|
|
50
50
|
// tighter bound because a routine has no human to notice it went sideways.
|
|
51
51
|
const MAX_FETCH_CHARS = 12_000;
|
|
52
52
|
|
|
53
|
-
function text(t: string) {
|
|
53
|
+
export function text(t: string) {
|
|
54
54
|
return { content: [{ type: "text", text: t }], details: {} };
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -76,7 +76,7 @@ function plain(s: string | undefined): string {
|
|
|
76
76
|
.trim();
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
interface RagFailure {
|
|
79
|
+
export interface RagFailure {
|
|
80
80
|
ok: false;
|
|
81
81
|
message: string;
|
|
82
82
|
}
|
|
@@ -87,7 +87,7 @@ interface RagFailure {
|
|
|
87
87
|
* swallowed: a routine that answers from memory because search quietly failed is
|
|
88
88
|
* worse than one that says the search failed.
|
|
89
89
|
*/
|
|
90
|
-
async function callRag<T>(path: string, body: unknown): Promise<({ ok: true } & { data: T }) | RagFailure> {
|
|
90
|
+
export async function callRag<T>(path: string, body: unknown): Promise<({ ok: true } & { data: T }) | RagFailure> {
|
|
91
91
|
let res: Response;
|
|
92
92
|
try {
|
|
93
93
|
res = await apiRequest(path, {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Privateer co-authors the commits it makes. When the agent runs `git commit` through
|
|
2
|
+
// its shell tool, the command is rewritten to carry
|
|
3
|
+
//
|
|
4
|
+
// --trailer 'Co-authored-by: Privateer <291203302+privateer-first-mate@users.noreply.github.com>'
|
|
5
|
+
//
|
|
6
|
+
// so a pushed commit shows Privateer beside the user on GitHub. The account is
|
|
7
|
+
// github.com/privateer-first-mate — a real USER account. The `privateer-agent` GitHub
|
|
8
|
+
// account is an organization, and an org has no commit email, so a trailer naming it
|
|
9
|
+
// would never link or show an avatar.
|
|
10
|
+
//
|
|
11
|
+
// Why the flag and not a prompt instruction: a model told to add a trailer forgets, or
|
|
12
|
+
// adds it to one commit of three. A rewrite in the tool_call hook (makePermissionGate —
|
|
13
|
+
// the one hook every surface runs bash through) cannot be skipped, and because it runs
|
|
14
|
+
// BEFORE the gate decides, the approval prompt shows the command that will really run.
|
|
15
|
+
//
|
|
16
|
+
// What is and isn't rewritten:
|
|
17
|
+
// - only `git [global opts] commit` at the START of a shell command (start of string,
|
|
18
|
+
// or after ; & | ( or a newline). `git commit-tree`/`commit-graph` are not commits.
|
|
19
|
+
// - never inside quotes: `-m "fix the git commit hook"` is message text, not a command.
|
|
20
|
+
// - never twice: a command already naming privateer-first-mate is left alone, and on
|
|
21
|
+
// `--amend` git's default trailer.ifExists (addIfDifferentNeighbor) won't duplicate
|
|
22
|
+
// a trailer that is already the last one.
|
|
23
|
+
// - never on git older than 2.32, which has no --trailer and would fail the commit.
|
|
24
|
+
//
|
|
25
|
+
// Off switch: PRIVATEER_COAUTHOR=0 (or off/false), or `"coauthor": false` in
|
|
26
|
+
// ~/.privateer/config.json.
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
import { configPath } from "../config/paths.ts";
|
|
31
|
+
|
|
32
|
+
export const COAUTHOR_TRAILER =
|
|
33
|
+
"Co-authored-by: Privateer <291203302+privateer-first-mate@users.noreply.github.com>";
|
|
34
|
+
|
|
35
|
+
const SHELL_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
|
|
36
|
+
|
|
37
|
+
// `git`, then any global options (`-C dir`, `-c k=v`, `--no-pager`, `--git-dir=x`),
|
|
38
|
+
// then `commit` as a whole word — not `commit-tree`, not `commitish`.
|
|
39
|
+
const GIT_COMMIT =
|
|
40
|
+
/(^|[;&|(\n])(\s*)(git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--?[\w-]+(?:=(?:"[^"]*"|'[^']*'|\S+))?))*\s+commit)(?=\s|$|[;&|)])/g;
|
|
41
|
+
|
|
42
|
+
/** Positions in `s` that are inside a single- or double-quoted string. */
|
|
43
|
+
function quotedMask(s: string): boolean[] {
|
|
44
|
+
const mask = new Array<boolean>(s.length).fill(false);
|
|
45
|
+
let quote: "'" | '"' | null = null;
|
|
46
|
+
for (let i = 0; i < s.length; i++) {
|
|
47
|
+
const c = s[i];
|
|
48
|
+
if (quote) {
|
|
49
|
+
mask[i] = true;
|
|
50
|
+
if (c === "\\" && quote === '"') {
|
|
51
|
+
if (i + 1 < s.length) mask[++i] = true;
|
|
52
|
+
} else if (c === quote) {
|
|
53
|
+
quote = null;
|
|
54
|
+
}
|
|
55
|
+
} else if (c === "\\") {
|
|
56
|
+
i++; // an escaped character outside quotes starts nothing
|
|
57
|
+
} else if (c === "'" || c === '"') {
|
|
58
|
+
quote = c;
|
|
59
|
+
mask[i] = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return mask;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `command` with the co-author trailer added to every `git commit` in it. */
|
|
66
|
+
export function withCoauthorTrailer(command: string): string {
|
|
67
|
+
if (!command.includes("commit") || command.includes("privateer-first-mate")) return command;
|
|
68
|
+
const mask = quotedMask(command);
|
|
69
|
+
const flag = ` --trailer '${COAUTHOR_TRAILER}'`;
|
|
70
|
+
return command.replace(GIT_COMMIT, (match, lead: string, space: string, git: string, offset: number) => {
|
|
71
|
+
const start = offset + lead.length + space.length;
|
|
72
|
+
if (mask[start] || mask[start + git.length - 1]) return match;
|
|
73
|
+
return `${lead}${space}${git}${flag}`;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let gitSupportsTrailer: boolean | undefined;
|
|
78
|
+
|
|
79
|
+
/** git >= 2.32 (the release that added `commit --trailer`). Probed once per process. */
|
|
80
|
+
function trailerSupported(): boolean {
|
|
81
|
+
if (gitSupportsTrailer !== undefined) return gitSupportsTrailer;
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync("git", ["--version"], { encoding: "utf8", timeout: 5_000 });
|
|
84
|
+
const m = /(\d+)\.(\d+)/.exec(out);
|
|
85
|
+
gitSupportsTrailer = !!m && (Number(m[1]) > 2 || (Number(m[1]) === 2 && Number(m[2]) >= 32));
|
|
86
|
+
} catch {
|
|
87
|
+
gitSupportsTrailer = false;
|
|
88
|
+
}
|
|
89
|
+
return gitSupportsTrailer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function coauthorEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
93
|
+
const flag = env.PRIVATEER_COAUTHOR?.trim().toLowerCase();
|
|
94
|
+
if (flag === "0" || flag === "off" || flag === "false") return false;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(readFileSync(configPath(), "utf8"))?.coauthor !== false;
|
|
97
|
+
} catch {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tool_call side: rewrite a shell tool's command in place (Pi executes the
|
|
104
|
+
* mutated `event.input`). Never throws — a failure here must not block a tool call.
|
|
105
|
+
*/
|
|
106
|
+
export function coauthorGitCommits(toolName: string, input: unknown, env: NodeJS.ProcessEnv = process.env): void {
|
|
107
|
+
try {
|
|
108
|
+
if (!SHELL_TOOLS.has(toolName.toLowerCase()) || !input || typeof input !== "object") return;
|
|
109
|
+
const obj = input as Record<string, unknown>;
|
|
110
|
+
const key = (["command", "cmd", "script"] as const).find((k) => typeof obj[k] === "string");
|
|
111
|
+
if (!key) return;
|
|
112
|
+
const command = obj[key] as string;
|
|
113
|
+
const next = withCoauthorTrailer(command);
|
|
114
|
+
if (next === command || !coauthorEnabled(env) || !trailerSupported()) return;
|
|
115
|
+
obj[key] = next;
|
|
116
|
+
} catch {
|
|
117
|
+
// leave the command as the model wrote it
|
|
118
|
+
}
|
|
119
|
+
}
|