privateer-agent 0.2.1 → 0.3.1

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 (111) hide show
  1. package/README.md +167 -442
  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 +57 -0
  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 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -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 +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +77 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -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
+ });
@@ -0,0 +1,172 @@
1
+ import { resolve, isAbsolute, relative } from "node:path";
2
+ import { isProtectedPath } from "./protected.ts";
3
+ import type { PermissionRequest } from "./gate.ts";
4
+
5
+ // NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
6
+ // before calling ctx.gate.request(). Pi's `tool_call` hook instead hands us
7
+ // { toolName, input }, so the classification — which kind of action is this, does
8
+ // it touch a protected/outside path — moves here. Field extraction is defensive
9
+ // across Pi's builtin input shapes (command/cmd, path/file_path/file).
10
+ //
11
+ // Returns null when the call needs no gate (a read-only builtin acting inside
12
+ // scope, or a known-safe meta tool): the hook then lets it run untouched, so
13
+ // ordinary in-cwd work has zero friction.
14
+
15
+ export interface ScopeOptions {
16
+ cwd: string;
17
+ confineToCwd?: boolean; // default true
18
+ allowedOutsideRoots?: string[];
19
+ }
20
+
21
+ function resolveInCwd(cwd: string, p: string): string {
22
+ return isAbsolute(p) ? p : resolve(cwd, p);
23
+ }
24
+
25
+ function isInsideDir(root: string, abs: string): boolean {
26
+ if (abs === root) return true;
27
+ const rel = relative(root, abs);
28
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
29
+ }
30
+
31
+ // Outside the agent's working-directory scope? Only when confinement is on and the
32
+ // path is neither inside cwd nor inside a session-approved outside root.
33
+ export function isOutsideScope(scope: ScopeOptions, abs: string): boolean {
34
+ if (scope.confineToCwd === false) return false;
35
+ if (isInsideDir(scope.cwd, abs)) return false;
36
+ return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
37
+ }
38
+
39
+ function str(v: unknown): string {
40
+ return typeof v === "string" ? v : v == null ? "" : String(v);
41
+ }
42
+
43
+ function firstPath(input: Record<string, unknown>): string {
44
+ return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
45
+ }
46
+
47
+ // Known-safe read-only / meta builtins that never mutate and never leave the
48
+ // machine: no gate regardless of arguments. Tunable — the conservative default for
49
+ // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
50
+ // builtin tool catalog as it's enumerated in Phase 5.
51
+ const NON_GATED = new Set([
52
+ "todo", "todowrite", "todo_write", "todoread", "think", "plan_note",
53
+ ]);
54
+
55
+ // Read-ish builtins: gated ONLY when the target resolves outside scope.
56
+ const READ_TOOLS = new Set(["read", "cat", "grep", "find", "glob", "ls", "tree", "view"]);
57
+ const EDIT_TOOLS = new Set(["edit", "edit_file", "str_replace", "str_replace_editor", "apply_patch", "patch"]);
58
+ const WRITE_TOOLS = new Set(["write", "write_file", "create_file", "create", "save_attachment"]);
59
+ const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
60
+
61
+ export function classifyToolCall(
62
+ toolName: string,
63
+ input: unknown,
64
+ scope: ScopeOptions,
65
+ ): PermissionRequest | null {
66
+ const name = toolName.toLowerCase();
67
+ const obj: Record<string, unknown> =
68
+ input && typeof input === "object" ? (input as Record<string, unknown>) : {};
69
+
70
+ if (NON_GATED.has(name)) return null;
71
+
72
+ // Creating a routine is a persistent mutation; surface the trigger + delivery, and
73
+ // force a human decision (alwaysAsk) when it grants off-machine egress.
74
+ if (name === "create_routine" || name === "routine") {
75
+ const label = str(obj.name) || "routine";
76
+ const trigger = obj.cron ? `cron ${str(obj.cron)}` : obj.at ? `at ${str(obj.at)}` : "(no trigger)";
77
+ const delivery = Array.isArray(obj.delivery) ? (obj.delivery as unknown[]).map(String) : ["file"];
78
+ const egress: string[] = [];
79
+ if (delivery.includes("email")) egress.push("email leaves the machine");
80
+ if (delivery.some((d) => d.startsWith("webhook:"))) egress.push("posts to a webhook off-machine");
81
+ return {
82
+ tool: toolName,
83
+ kind: "write",
84
+ title: "Create routine",
85
+ detail: `${label}: ${trigger} → ${delivery.join(",")}${egress.length ? ` [${egress.join("] [")}]` : ""}`,
86
+ alwaysAsk: egress.length > 0,
87
+ };
88
+ }
89
+
90
+ // Shell — the whole command is the detail (danger scanning runs on it).
91
+ if (BASH_TOOLS.has(name)) {
92
+ const command = str(obj.command ?? obj.cmd ?? obj.script);
93
+ return { tool: toolName, kind: "bash", title: "Run command", detail: command };
94
+ }
95
+
96
+ // Write — create/overwrite a file.
97
+ if (WRITE_TOOLS.has(name)) {
98
+ const p = firstPath(obj);
99
+ const abs = resolveInCwd(scope.cwd, p);
100
+ const outside = isOutsideScope(scope, abs);
101
+ return {
102
+ tool: toolName,
103
+ kind: "write",
104
+ title: outside ? "Write outside working directory" : "Write file",
105
+ detail: outside ? abs : p,
106
+ protected: isProtectedPath(abs),
107
+ outside,
108
+ path: abs,
109
+ };
110
+ }
111
+
112
+ // Edit — modify an existing file.
113
+ if (EDIT_TOOLS.has(name)) {
114
+ const p = firstPath(obj);
115
+ const abs = resolveInCwd(scope.cwd, p);
116
+ const outside = isOutsideScope(scope, abs);
117
+ return {
118
+ tool: toolName,
119
+ kind: "edit",
120
+ title: outside ? "Edit outside working directory" : "Edit file",
121
+ detail: outside ? abs : p,
122
+ protected: isProtectedPath(abs),
123
+ outside,
124
+ path: abs,
125
+ };
126
+ }
127
+
128
+ // Read-ish — no gate in scope; when the target is outside scope, prompt.
129
+ if (READ_TOOLS.has(name)) {
130
+ const p = firstPath(obj);
131
+ if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
132
+ const abs = resolveInCwd(scope.cwd, p);
133
+ if (!isOutsideScope(scope, abs)) return null;
134
+ return {
135
+ tool: toolName,
136
+ kind: "read",
137
+ title: "Read outside working directory",
138
+ detail: abs,
139
+ outside: true,
140
+ path: abs,
141
+ };
142
+ }
143
+
144
+ // Network reads (web fetch / search / http).
145
+ if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
146
+ return {
147
+ tool: toolName,
148
+ kind: "fetch",
149
+ title: "Fetch from the network",
150
+ detail: str(obj.url ?? obj.query ?? obj.q),
151
+ };
152
+ }
153
+
154
+ // Unknown / custom / MCP tool: we can't prove it's side-effect-free, so
155
+ // safe-by-default is to prompt. Classified as a bash-kind action (asks in
156
+ // default/acceptEdits, denies in plan, allows only under bypass). Phase 5 refines
157
+ // this with MCP destructiveHint → alwaysAsk and a per-tool policy map.
158
+ return {
159
+ tool: toolName,
160
+ kind: "bash",
161
+ title: `Run ${toolName}`,
162
+ detail: safeJson(obj),
163
+ };
164
+ }
165
+
166
+ function safeJson(v: unknown): string {
167
+ try {
168
+ return JSON.stringify(v);
169
+ } catch {
170
+ return String(v);
171
+ }
172
+ }
@@ -1,8 +1,10 @@
1
- // The permission gate is the seam between tools and the user's approval policy.
2
- // Tools call `gate.request(...)` before any mutation or shell execution; the gate
3
- // decides allow/deny based on the active permission mode (Phase 4) and may prompt
4
- // the user interactively. Phase 2 ships a pass-through gate so tools work
5
- // end-to-end; Phase 4 replaces it with the mode-aware interactive gate.
1
+ // Permission-gate vocabulary the request shape the policy reasons about, and
2
+ // the decision it returns. Ported from tree-cli/src/permissions/gate.ts, trimmed
3
+ // to types only: in the 0.2 codebase each tool built a PermissionRequest and
4
+ // called ctx.gate.request(); in the Pi rewrite the gate is a `tool_call`
5
+ // extension hook that receives { toolName, input } and derives the request via
6
+ // classifyToolCall (see ./classify.ts). So the pass-through gate is gone; the
7
+ // live policy lives in ./mode.ts + ./modeGate.ts.
6
8
 
7
9
  export type PermissionDecision = "allow" | "deny";
8
10
 
@@ -15,9 +17,9 @@ export interface PermissionRequest {
15
17
  detail: string; // the command, or file path + change preview
16
18
  protected?: boolean; // target is a guarded file: never auto-approve, always prompt
17
19
  // Always require a human decision, ABOVE bypass mode and the allowlist (like a
18
- // dangerous shell command). Set for MCP tools that declare themselves
19
- // destructive (destructiveHint), so even a "take no prisoners" run can't fire
20
- // an irreversible external action silently. The decision is never remembered.
20
+ // dangerous shell command). Set for tools that declare themselves destructive,
21
+ // so even a "take no prisoners" run can't fire an irreversible action silently.
22
+ // The decision is never remembered.
21
23
  alwaysAsk?: boolean;
22
24
  // Target resolves outside the working directory: never auto-approve (unless bypass),
23
25
  // always prompt. `path` carries the absolute target so "always" can remember its dir.
@@ -25,16 +27,11 @@ export interface PermissionRequest {
25
27
  path?: string;
26
28
  }
27
29
 
30
+ // Implemented by ModeGate (./modeGate.ts). The extension builds one per tool_call.
28
31
  export interface PermissionGate {
29
32
  request(req: PermissionRequest): Promise<PermissionDecision>;
30
33
  }
31
34
 
32
- export const autoApproveGate: PermissionGate = {
33
- async request() {
34
- return "allow";
35
- },
36
- };
37
-
38
35
  export class PermissionDeniedError extends Error {
39
36
  constructor(tool: string) {
40
37
  super(`Permission denied for ${tool}`);
@@ -1,7 +1,11 @@
1
- import type { PermissionMode } from "../config/schema.ts";
1
+ import type { PermissionMode } from "../config/permissionMode.ts";
2
2
  import type { PermissionRequest } from "./gate.ts";
3
3
  import { isDangerousCommand } from "./danger.ts";
4
4
 
5
+ // Ported verbatim from tree-cli/src/permissions/mode.ts (only the PermissionMode
6
+ // import path changed). This is the pure mode/allowlist policy the gate consults
7
+ // before ever involving the user.
8
+
5
9
  export type AutoDecision = "allow" | "deny" | "ask";
6
10
 
7
11
  // Is a bash command covered by the allowlist? Entries are command prefixes:
@@ -27,7 +31,7 @@ export function decideAuto(
27
31
  // Dangerous shell (destructive / secret-exfil) always confirms — this sits
28
32
  // above bypass and the allowlist so an injected command can't run silently.
29
33
  if (req.kind === "bash" && isDangerousCommand(req.detail, denylist)) return "ask";
30
- // Explicitly destructive actions (e.g. an MCP tool that declares destructiveHint)
34
+ // Explicitly destructive actions (e.g. a tool that declares destructiveHint)
31
35
  // always confirm too — also above bypass, so "skip permissions" can't fire them
32
36
  // blind.
33
37
  if (req.alwaysAsk) return "ask";