privateer-agent 0.1.1 → 0.3.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.
Files changed (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -17,8 +17,11 @@ interface ErrorFacts {
17
17
  statusCode?: number;
18
18
  providerMessage?: string;
19
19
  code?: string; // provider's machine-readable error code, e.g. "DAILY_CAP_HIT"
20
+ errno?: string; // Node socket-level code, e.g. "ECONNREFUSED"
20
21
  model?: string;
21
22
  provider?: string;
23
+ url?: string; // the endpoint the failing request targeted
24
+ message?: string; // deepest non-empty Error message in the chain
22
25
  }
23
26
 
24
27
  const HOST_LABELS: Record<string, string> = {
@@ -26,70 +29,81 @@ const HOST_LABELS: Record<string, string> = {
26
29
  "api.anthropic.com": "Anthropic",
27
30
  "api.openai.com": "OpenAI",
28
31
  "cloud-api.near.ai": "NEAR AI",
32
+ "localhost:11434": "Ollama",
33
+ "127.0.0.1:11434": "Ollama",
29
34
  };
30
35
 
31
- // The AI SDK wraps the real provider error: a retry sequence that exhausts its
32
- // attempts throws AI_RetryError, whose `.lastError` is the APICallError that
33
- // actually carries statusCode / responseBody / requestBodyValues. Without peeling
34
- // that off we'd read undefined for every field and fall back to the wrapper's bare
35
- // "Too Many Requests". Follow `.lastError` (and a `.cause` that looks like a
36
- // richer error) until we reach the one with the useful fields.
37
- function unwrap(err: unknown): unknown {
38
- let cur = err;
39
- for (let i = 0; i < 5; i++) {
40
- if (!cur || typeof cur !== "object") break;
41
- const e = cur as Record<string, unknown>;
42
- const richer = e.statusCode == null && e.responseBody == null;
43
- const inner = e.lastError ?? (richer ? e.cause : undefined);
44
- if (!inner || inner === cur) break;
45
- cur = inner;
46
- }
47
- return cur;
48
- }
36
+ // Socket-level failures Node/undici report via an error `code`. Distinct from the
37
+ // provider's machine code (which comes out of the response body) — these mean the
38
+ // request never got a response at all.
39
+ const NETWORK_ERRNO =
40
+ /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
49
41
 
50
42
  // Pull structured fields off an unknown error without trusting any one shape.
43
+ //
44
+ // The AI SDK nests the useful error: retry exhaustion throws AI_RetryError, whose
45
+ // `.lastError` is the APICallError carrying statusCode / url / responseBody, whose
46
+ // `.cause` on a connection failure is the socket error (often an AggregateError
47
+ // with an EMPTY message but an errno code). No single level has everything, so
48
+ // walk the whole chain and merge: HTTP-ish facts keep the first (shallowest)
49
+ // value found, while `message` keeps the deepest NON-EMPTY one — inner messages
50
+ // are more specific ("Cannot connect to API") than the wrapper's ("Failed after
51
+ // 3 attempts…"), but the socket error at the very bottom may have none at all.
51
52
  function extract(err: unknown): ErrorFacts {
52
- const e = (err ?? {}) as Record<string, unknown>;
53
- const statusCode = typeof e.statusCode === "number" ? e.statusCode : undefined;
53
+ const facts: ErrorFacts = {};
54
54
 
55
55
  // The provider's own message + machine code, preferred over the SDK's wrapper
56
56
  // text. Providers disagree on shape: OpenAI/OpenRouter nest under `error`, while
57
57
  // the Privateer account backend returns a flat `{ message, code }` (e.g. a daily
58
58
  // usage cap). Read both shapes; keep the first message/code we find.
59
- let providerMessage: string | undefined;
60
- let code: string | undefined;
61
59
  const readBody = (body: unknown) => {
62
60
  const b = body as
63
61
  | { error?: { message?: unknown; code?: unknown }; message?: unknown; code?: unknown }
64
62
  | undefined;
65
63
  if (!b || typeof b !== "object") return;
66
64
  const msg = b.error?.message ?? b.message;
67
- if (providerMessage == null && typeof msg === "string") providerMessage = msg;
65
+ if (facts.providerMessage == null && typeof msg === "string") facts.providerMessage = msg;
68
66
  const c = b.error?.code ?? b.code;
69
- if (code == null && typeof c === "string") code = c;
67
+ if (facts.code == null && typeof c === "string") facts.code = c;
70
68
  };
71
- readBody(e.data);
72
- if (typeof e.responseBody === "string") {
73
- try {
74
- readBody(JSON.parse(e.responseBody));
75
- } catch {
76
- /* responseBody wasn't JSON — fall back to the wrapper message */
69
+
70
+ let cur: unknown = err;
71
+ for (let i = 0; i < 6 && cur && typeof cur === "object"; i++) {
72
+ const e = cur as Record<string, unknown>;
73
+
74
+ if (facts.statusCode == null && typeof e.statusCode === "number") facts.statusCode = e.statusCode;
75
+ readBody(e.data);
76
+ if (typeof e.responseBody === "string") {
77
+ try {
78
+ readBody(JSON.parse(e.responseBody));
79
+ } catch {
80
+ /* responseBody wasn't JSON — fall back to the wrapper message */
81
+ }
77
82
  }
78
- }
79
83
 
80
- const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
81
- const model = typeof reqBody?.model === "string" ? reqBody.model : undefined;
84
+ const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
85
+ if (facts.model == null && typeof reqBody?.model === "string") facts.model = reqBody.model;
82
86
 
83
- let provider: string | undefined;
84
- if (typeof e.url === "string") {
85
- try {
86
- provider = HOST_LABELS[new URL(e.url).host];
87
- } catch {
88
- /* not a URL */
87
+ if (facts.url == null && typeof e.url === "string" && /^https?:/.test(e.url)) {
88
+ facts.url = e.url;
89
+ try {
90
+ facts.provider = HOST_LABELS[new URL(e.url).host];
91
+ } catch {
92
+ /* not a URL */
93
+ }
89
94
  }
95
+
96
+ if (facts.errno == null && typeof e.code === "string" && NETWORK_ERRNO.test(e.code)) {
97
+ facts.errno = e.code;
98
+ }
99
+ if (typeof e.message === "string" && e.message.trim()) facts.message = e.message;
100
+
101
+ const next = e.lastError ?? e.cause;
102
+ if (!next || next === cur) break;
103
+ cur = next;
90
104
  }
91
105
 
92
- return { statusCode, providerMessage, code, model, provider };
106
+ return facts;
93
107
  }
94
108
 
95
109
  // Machine codes the Privateer backend returns for a hard account cap (daily /
@@ -116,10 +130,9 @@ function rawMessage(err: unknown): string {
116
130
  // Map a provider error to a friendly message + hint. Falls back to the raw
117
131
  // (redacted) message for anything we don't recognize, so nothing is swallowed.
118
132
  export function describeError(err: unknown): DescribedError {
119
- const inner = unwrap(err);
120
- const facts = extract(inner);
133
+ const facts = extract(err);
121
134
  const status = facts.statusCode;
122
- const text = facts.providerMessage ?? rawMessage(inner);
135
+ const text = facts.providerMessage ?? facts.message ?? rawMessage(err);
123
136
  const forModel = facts.model ? ` for ${facts.model}` : "";
124
137
  const forProvider = facts.provider ? ` for ${facts.provider}` : "";
125
138
 
@@ -153,6 +166,16 @@ export function describeError(err: unknown): DescribedError {
153
166
  hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
154
167
  });
155
168
  }
169
+ // Privateer machine-login expiry (thrown by the session spawn after the
170
+ // server rejects the parent refresh token). The stored credentials are
171
+ // already wiped; the only fix is a fresh /login, so say exactly that and
172
+ // never mark it retryable.
173
+ if (/privateer session expired/i.test(text)) {
174
+ return out({
175
+ message: "Your Privateer session expired — this terminal was signed out.",
176
+ hint: "Run /login to sign back in to your Privateer account.",
177
+ });
178
+ }
156
179
  if (status === 401 || status === 403) {
157
180
  return out({
158
181
  message: `Authentication failed${forProvider} (${status}).`,
@@ -185,9 +208,36 @@ export function describeError(err: unknown): DescribedError {
185
208
  retryable: true,
186
209
  });
187
210
  }
188
- if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)) {
211
+ if (
212
+ facts.errno != null ||
213
+ /fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
214
+ ) {
215
+ let origin: string | undefined;
216
+ let localhost = false;
217
+ if (facts.url) {
218
+ try {
219
+ const u = new URL(facts.url);
220
+ origin = u.origin;
221
+ localhost = /^(localhost$|127\.|0\.0\.0\.0$|\[::1\]$)/.test(u.hostname);
222
+ } catch {
223
+ /* not a URL */
224
+ }
225
+ }
226
+ // A local inference server that refuses connections isn't a flaky network —
227
+ // it isn't running. Say which one and how to start it; retrying won't help
228
+ // until the user acts, so no `retryable` flag.
229
+ if (localhost) {
230
+ const label = facts.provider ?? "the local server";
231
+ return out({
232
+ message: `Cannot connect to ${label} at ${origin} — nothing is listening there.`,
233
+ hint:
234
+ facts.provider === "Ollama"
235
+ ? "Start Ollama (run `ollama serve`, or open the Ollama app), then try again — or run /model to switch models."
236
+ : "Start the server (or check its base URL), then try again — or run /model to switch models.",
237
+ });
238
+ }
189
239
  return out({
190
- message: "Network error reaching the provider.",
240
+ message: `Network error reaching ${facts.provider ?? origin ?? "the provider"}.`,
191
241
  hint: "Check your connection and try again.",
192
242
  retryable: true,
193
243
  });
@@ -1,165 +1,11 @@
1
- import type { LanguageModel, ModelMessage } from "ai";
2
- import type { Modality } from "../util/images.ts";
3
-
4
- export type { Modality };
5
-
6
- // The named routes the engine can switch between per turn. `default` is always
7
- // present (it's the session's configured model); the rest are optional specialized
8
- // targets resolved from the `router` config block.
9
- export type RouteName = "default" | "vision" | "document" | "audio" | "video" | "long" | "fast";
10
-
11
- // Routes that exist to satisfy a specific input modality, in the order we prefer them
12
- // when several could cover a turn's requirements.
13
- const MODALITY_ROUTES: { name: RouteName; modality: Modality }[] = [
14
- { name: "vision", modality: "image" },
15
- { name: "document", modality: "document" },
16
- { name: "audio", modality: "audio" },
17
- { name: "video", modality: "video" },
18
- ];
19
-
20
- // A resolved, ready-to-stream model plus the per-model knobs that travel with it
21
- // (Anthropic caching/thinking depend on the model family, so they're per-route) and
22
- // the set of input modalities it accepts (so the router can satisfy a turn's needs).
23
- export interface Route {
24
- spec: string; // "provider:model"
25
- model: LanguageModel;
26
- cacheControl: boolean;
27
- thinkingBudget?: number;
28
- label: string; // short display name for UI notices (the model id tail)
29
- supports: Set<Modality>;
30
- }
31
-
32
- export interface RouteSet {
33
- default: Route;
34
- vision?: Route;
35
- document?: Route;
36
- audio?: Route;
37
- video?: Route;
38
- long?: Route;
39
- fast?: Route;
40
- longThreshold: number; // estimated tokens above which `long` applies
41
- fastMaxChars: number; // prompt length at/below which `fast` applies
42
- }
43
-
44
- // What the current turn looks like, used to pick a route.
45
- export interface RouteSignals {
46
- modalities: Set<Modality>; // input modalities anywhere in the conversation (sticky)
47
- estTokens: number; // estimated tokens of the whole conversation
48
- promptChars: number; // length of the new user prompt
49
- }
50
-
51
- export interface RouteSelection {
52
- name: RouteName;
53
- route: Route;
54
- reason?: string; // short human reason, for the UI notice
55
- missing?: Modality[]; // modalities the chosen route can't satisfy (warn)
56
- }
57
-
58
- function covers(route: Route | undefined, required: Set<Modality>): route is Route {
59
- if (!route) return false;
60
- for (const m of required) if (!route.supports.has(m)) return false;
61
- return true;
62
- }
63
-
64
- function missingFrom(route: Route, required: Set<Modality>): Modality[] {
65
- return [...required].filter((m) => !route.supports.has(m));
66
- }
67
-
68
- // Candidate routes to consider for an attachment-bearing turn, in preference order:
69
- // the dedicated modality routes first, then long/fast/default as fallbacks (they may
70
- // happen to be multimodal). Deduped by identity.
71
- function candidateRoutes(routes: RouteSet): Route[] {
72
- const ordered = [
73
- ...MODALITY_ROUTES.map((r) => routes[r.name]),
74
- routes.long,
75
- routes.fast,
76
- routes.default,
77
- ].filter((r): r is Route => Boolean(r));
78
- return [...new Set(ordered)];
79
- }
80
-
81
- // Pick the model for this turn.
82
- // • If the turn requires input modalities, choose the highest-preference route whose
83
- // `supports` covers the whole union (capability is a hard requirement). If none
84
- // fully covers, fall back to the route covering the most, flagging what's missing.
85
- // • Otherwise apply the soft preferences: long (large context) > fast (short prompt)
86
- // > default.
87
- export function selectRoute(routes: RouteSet, s: RouteSignals): RouteSelection {
88
- if (s.modalities.size > 0) {
89
- const candidates = candidateRoutes(routes);
90
- const full = candidates.find((r) => covers(r, s.modalities));
91
- if (full) {
92
- const name = routeNameOf(routes, full);
93
- return { name, route: full, reason: reasonFor(s.modalities) };
94
- }
95
- // No single route covers everything — pick the one that covers the most and warn.
96
- let best = routes.default;
97
- let bestMissing = missingFrom(best, s.modalities);
98
- for (const r of candidates) {
99
- const miss = missingFrom(r, s.modalities);
100
- if (miss.length < bestMissing.length) {
101
- best = r;
102
- bestMissing = miss;
103
- }
104
- }
105
- return {
106
- name: routeNameOf(routes, best),
107
- route: best,
108
- reason: reasonFor(s.modalities),
109
- missing: bestMissing,
110
- };
111
- }
112
- if (routes.long && s.estTokens > routes.longThreshold) {
113
- return { name: "long", route: routes.long, reason: "long context" };
114
- }
115
- if (routes.fast && s.promptChars <= routes.fastMaxChars) {
116
- return { name: "fast", route: routes.fast, reason: "short prompt" };
117
- }
118
- return { name: "default", route: routes.default };
119
- }
120
-
121
- function reasonFor(modalities: Set<Modality>): string {
122
- return `${[...modalities].join("+")} input`;
123
- }
124
-
125
- // Recover the route name for a resolved Route (for the UI/event). Falls back to
126
- // "default" if it isn't one of the named slots.
127
- function routeNameOf(routes: RouteSet, route: Route): RouteName {
128
- const names: RouteName[] = ["vision", "document", "audio", "video", "long", "fast"];
129
- for (const n of names) if (routes[n] === route) return n;
130
- return "default";
131
- }
132
-
133
- const MEDIA_MODALITY: { test: RegExp; modality: Modality }[] = [
134
- { test: /^image\//, modality: "image" },
135
- { test: /^audio\//, modality: "audio" },
136
- { test: /^video\//, modality: "video" },
137
- { test: /^application\/pdf$/, modality: "document" },
138
- ];
139
-
140
- function modalityOfMediaType(mediaType: string): Modality | null {
141
- return MEDIA_MODALITY.find((m) => m.test.test(mediaType))?.modality ?? null;
142
- }
143
-
144
- // The input modalities present anywhere in the conversation. Keyed off the whole
145
- // history (not just the new message) so that once an attachment is in context,
146
- // follow-up turns stay on a model that can accept it rather than replaying it to one
147
- // that can't.
148
- export function requiredModalities(messages: ModelMessage[]): Set<Modality> {
149
- const out = new Set<Modality>();
150
- for (const m of messages) {
151
- const content = m.content;
152
- if (!Array.isArray(content)) continue;
153
- for (const part of content) {
154
- if (!part || typeof part !== "object" || !("type" in part)) continue;
155
- const t = (part as { type: string }).type;
156
- if (t === "image") out.add("image");
157
- else if (t === "file") {
158
- const mt = (part as { mediaType?: string }).mediaType;
159
- const mod = mt ? modalityOfMediaType(mt) : null;
160
- if (mod) out.add(mod);
161
- }
162
- }
163
- }
164
- return out;
165
- }
1
+ // Route/modality types referenced by the EngineEvent `routed` variant.
2
+ //
3
+ // STUB — the full router (tree-cli/src/engine/router.ts) is DELETE in the
4
+ // migration (Pi's model registry + our models.json generator replace it). Only
5
+ // the two type names survive as part of the relay wire vocabulary, so they live
6
+ // here as a types-only module until the `routed` event's producer is wired in a
7
+ // later phase. Kept structurally identical to the original so events.ts ports
8
+ // verbatim.
9
+ export type Modality = "text" | "image" | "audio" | "video" | "pdf";
10
+
11
+ export type RouteName = "default" | "vision" | "long-context" | "fast" | (string & {});
@@ -0,0 +1,216 @@
1
+ // The permission gate — the safe-by-default moat, as a Pi extension.
2
+ //
3
+ // Phase 2. Ties together the ported policy: a `tool_call` hook classifies the
4
+ // call (./…/classify.ts), runs it through the ModeGate policy engine
5
+ // (./…/modeGate.ts → decideAuto), and blocks the tool when the decision is deny.
6
+ // The gate SUSPENDS the turn while an approval is pending (spike-B proven), routes
7
+ // to a LOCAL prompt (ctx.ui) or a REMOTE approver (the relay, Phase 4), and is
8
+ // FAIL-CLOSED: any throw, timeout, or abort blocks the tool. A `tool_result` hook
9
+ // redacts secrets from output before it reaches the model / relay.
10
+ //
11
+ // The load-bearing decision path is `decideToolCall`, kept pure and Pi-free so the
12
+ // ported tests exercise it without a live session.
13
+
14
+ import type { PermissionMode } from "../config/permissionMode.ts";
15
+ import type { PermissionRequest } from "../permissions/gate.ts";
16
+ import { ModeGate, type AskOutcome } from "../permissions/modeGate.ts";
17
+ import { classifyToolCall } from "../permissions/classify.ts";
18
+ import { redactText } from "../util/redact.ts";
19
+ import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
20
+
21
+ // The per-tool_call hook context we read (structural subset of Pi's ctx — kept
22
+ // local so the gate stays import-order-safe and unit-testable).
23
+ export interface ToolCallCtx {
24
+ signal?: AbortSignal;
25
+ hasUI?: boolean;
26
+ mode?: "tui" | "rpc" | "json" | "print" | string;
27
+ // Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
28
+ // select(title, options: string[]) → chosen string | undefined; confirm(title, message).
29
+ ui?: {
30
+ select?: (title: string, options: string[], opts?: unknown) => Promise<string | undefined>;
31
+ confirm?: (title: string, message: string, opts?: unknown) => Promise<boolean>;
32
+ };
33
+ }
34
+
35
+ // Session-scoped state + askers. Mutable fields (mode/allowlist/allowedOutsideRoots)
36
+ // persist across tool_calls; the askers are invoked per call.
37
+ export interface GateController {
38
+ getMode(): PermissionMode;
39
+ setMode(mode: PermissionMode): void;
40
+ allowlist: string[];
41
+ allowedOutsideRoots: string[];
42
+ denylist?: string[];
43
+ cwd: string;
44
+ confineToCwd?: boolean;
45
+ getRemote?(): boolean;
46
+ getNoQuarter?(): boolean;
47
+ // Local interactive approval via the per-call ctx. Default provided below.
48
+ localAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome>;
49
+ // Remote (relay) approval. Optional until Phase 4; when absent, a remote turn
50
+ // falls back to localAsk.
51
+ remoteAsk?(req: PermissionRequest, signal?: AbortSignal): Promise<AskOutcome>;
52
+ // Secret redactor for tool output. Defaults to redactText.
53
+ redact?(text: string): string;
54
+ // Hard ceiling on waiting for a decision before failing closed.
55
+ approvalTimeoutMs?: number;
56
+ }
57
+
58
+ export interface GateBlock {
59
+ block: true;
60
+ reason: string;
61
+ }
62
+
63
+ // THE decision path. Classify → policy → decision, fail-closed on any error.
64
+ // Returns a block directive to deny, or undefined to let the tool run.
65
+ export async function decideToolCall(
66
+ ctrl: GateController,
67
+ toolName: string,
68
+ input: unknown,
69
+ ctx: ToolCallCtx,
70
+ ): Promise<GateBlock | undefined> {
71
+ const req = classifyToolCall(toolName, input, {
72
+ cwd: ctrl.cwd,
73
+ confineToCwd: ctrl.confineToCwd,
74
+ allowedOutsideRoots: ctrl.allowedOutsideRoots,
75
+ });
76
+ if (!req) return undefined; // no gate needed — read-only/in-scope/meta
77
+
78
+ // Route the ask: a remote-driven turn goes to the relay approver (if wired),
79
+ // otherwise the local UI. ModeGate decides *whether* to ask; this decides *who*.
80
+ const ask = (r: PermissionRequest): Promise<AskOutcome> =>
81
+ ctrl.getRemote?.() && ctrl.remoteAsk
82
+ ? ctrl.remoteAsk(r, ctx.signal)
83
+ : ctrl.localAsk(r, ctx);
84
+
85
+ const gate = new ModeGate({
86
+ getMode: ctrl.getMode,
87
+ setMode: ctrl.setMode,
88
+ allowlist: ctrl.allowlist,
89
+ allowedOutsideRoots: ctrl.allowedOutsideRoots,
90
+ // Default to the built-in dangerous-command patterns so bypass / no-quarter /
91
+ // headless-subagent runs still force dangerous shell + secret-exfil to "ask"
92
+ // (→ headless deny). A controller can extend, but never silently disable, this.
93
+ denylist: ctrl.denylist ?? DEFAULT_DENYLIST,
94
+ ask,
95
+ getRemote: ctrl.getRemote,
96
+ getNoQuarter: ctrl.getNoQuarter,
97
+ });
98
+
99
+ let decision: "allow" | "deny";
100
+ try {
101
+ decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
102
+ } catch (err) {
103
+ // Fail closed: a thrown/aborted/timed-out approval blocks the tool.
104
+ return {
105
+ block: true,
106
+ reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default`,
107
+ };
108
+ }
109
+ if (decision === "deny") {
110
+ return { block: true, reason: `${req.title} denied by permission gate` };
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ // Default local asker: prompt via ctx.ui when a UI is present; fail closed
116
+ // (deny) when headless (no UI) — the gate is the real safety, never auto-trust.
117
+ // TODO(verify) the exact ctx.ui.select/confirm API shape against Pi when the TUI
118
+ // is wired (Phase 6); coded defensively for now.
119
+ const ALLOW_ONCE = "Allow once";
120
+ const ALLOW_ALWAYS = "Allow and remember";
121
+ const DENY = "Deny";
122
+
123
+ export async function defaultLocalAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome> {
124
+ if (!ctx.hasUI || !ctx.ui) return "deny"; // headless → fail closed
125
+ const title = `${req.title}: ${req.detail}`;
126
+ if (typeof ctx.ui.select === "function") {
127
+ const choice = await ctx.ui.select(title, [ALLOW_ONCE, ALLOW_ALWAYS, DENY]);
128
+ if (choice === ALLOW_ONCE) return "allow";
129
+ if (choice === ALLOW_ALWAYS) return "always";
130
+ return "deny"; // Deny, or cancel (undefined)
131
+ }
132
+ if (typeof ctx.ui.confirm === "function") {
133
+ return (await ctx.ui.confirm("Permission", title)) ? "allow" : "deny";
134
+ }
135
+ return "deny";
136
+ }
137
+
138
+ // Build the extension factory. Pass to DefaultResourceLoader({ extensionFactories }).
139
+ export function makePermissionGate(ctrl: GateController) {
140
+ const redact = ctrl.redact ?? redactText;
141
+ if (!ctrl.localAsk) ctrl.localAsk = defaultLocalAsk;
142
+
143
+ // Structural view of the Pi extension API surface we use (documented at
144
+ // pi.dev/docs/latest/extensions).
145
+ interface PiExtensionApi {
146
+ on(
147
+ event: "tool_call",
148
+ handler: (
149
+ event: { toolName: string; toolCallId: string; input: unknown },
150
+ ctx: ToolCallCtx,
151
+ ) => Promise<GateBlock | undefined>,
152
+ ): void;
153
+ on(
154
+ event: "tool_result",
155
+ handler: (
156
+ event: {
157
+ toolName: string;
158
+ toolCallId: string;
159
+ content: Array<{ type: string; text?: string; [k: string]: unknown }>;
160
+ details?: unknown;
161
+ isError?: boolean;
162
+ },
163
+ ctx: unknown,
164
+ ) => { content?: unknown[] } | undefined,
165
+ ): void;
166
+ }
167
+
168
+ return function permissionGate(pi: PiExtensionApi): void {
169
+ pi.on("tool_call", (event, ctx) => decideToolCall(ctrl, event.toolName, event.input, ctx));
170
+
171
+ // Redact secrets from tool output before it reaches the model / relay.
172
+ // tool_result delivers content PARTS (per the Pi extension contract), not a
173
+ // bare string — patch text parts and return a partial { content }; omitted
174
+ // fields keep their current values.
175
+ pi.on("tool_result", (event) => {
176
+ let touched = false;
177
+ const content = event.content.map((part) => {
178
+ if (typeof part.text === "string") {
179
+ const red = redact(part.text);
180
+ if (red !== part.text) touched = true;
181
+ return { ...part, text: red };
182
+ }
183
+ return part;
184
+ });
185
+ return touched ? { content } : undefined;
186
+ });
187
+ };
188
+ }
189
+
190
+ function withTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal): Promise<T> {
191
+ if (!ms && !signal) return p;
192
+ return new Promise<T>((resolve, reject) => {
193
+ let timer: ReturnType<typeof setTimeout> | undefined;
194
+ const onAbort = () => {
195
+ clearTimeout(timer);
196
+ reject(new Error("aborted"));
197
+ };
198
+ if (ms) timer = setTimeout(() => reject(new Error("approval timeout")), ms);
199
+ if (signal) {
200
+ if (signal.aborted) return onAbort();
201
+ signal.addEventListener("abort", onAbort, { once: true });
202
+ }
203
+ p.then(
204
+ (v) => {
205
+ clearTimeout(timer);
206
+ signal?.removeEventListener("abort", onAbort);
207
+ resolve(v);
208
+ },
209
+ (e) => {
210
+ clearTimeout(timer);
211
+ signal?.removeEventListener("abort", onAbort);
212
+ reject(e);
213
+ },
214
+ );
215
+ });
216
+ }
package/src/main.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Entrypoint. The ONE ordering rule: import ./boot.ts first (env +
2
+ // attestation dispatcher), then dynamically import everything Pi-touching so
3
+ // boot's side effects are guaranteed to have run before any Pi module loads.
4
+ //
5
+ // Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
6
+ // the boot chain end-to-end (env pinned, dispatcher installed) without a TUI.
7
+ // Phases 4/6 replace the body with the daemon/relay wiring and the pi-tui app.
8
+
9
+ import "./boot.ts";
10
+
11
+ async function main() {
12
+ // Only import Pi-touching / config code AFTER boot has run.
13
+ const { agentDir, globalDir } = await import("./config/paths.ts");
14
+ const { capturedHosts } = await import("pi-privacy/attest");
15
+
16
+ const home = globalDir();
17
+ const agent = process.env.PI_CODING_AGENT_DIR ?? agentDir();
18
+
19
+ console.log("privateer-agent 0.3 — boot skeleton");
20
+ console.log(` PRIVATEER_HOME ${home}`);
21
+ console.log(` PI_CODING_AGENT_DIR ${agent}`);
22
+ console.log(` attestation dispatcher installed: yes`);
23
+ console.log(` hosts attested so far: ${capturedHosts().size}`);
24
+ console.log("");
25
+ console.log("Phases 1–2 skeleton in place (adapter + gate promoted from pi-spike).");
26
+ console.log("Next: wire a real headless session — see docs/pi-migration-plan.md §2.");
27
+ }
28
+
29
+ main().catch((err) => {
30
+ console.error(err?.stack ?? err);
31
+ process.exit(1);
32
+ });