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.
- package/README.md +167 -442
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +77 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/engine/router.ts
CHANGED
|
@@ -1,165 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
export type
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
}
|
package/src/permissions/gate.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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}`);
|
package/src/permissions/mode.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type { PermissionMode } from "../config/
|
|
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.
|
|
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";
|