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.
@@ -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 ⏎ select type to search esc cancel"),
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.51",
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
- const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
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.
@@ -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
- const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
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
@@ -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
- pi.on("tool_call", (event, ctx) => decideToolCall(ctrl, event.toolName, event.input, ctx));
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
@@ -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
- const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
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 NON-idempotent (each runs a headless session), so they
583
- // require a strictly-fresh ts — a replayed frame with an equal ts must NOT re-run.
584
- // The idempotent config mutations (routines/channels save|delete) keep the default
585
- // at-or-above acceptance. See authorizeControl's strict note.
586
- const strict = action === "task_submit" || action === "task_spawn" || action === "workflows_run";
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 a headless session), where a malicious
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
- case "approval_response":
769
- if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
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
- case "no_quarter":
772
- this.cb.onNoQuarter?.(frame.on === true);
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
+ }