oakode 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +9 -0
  3. package/README.md +243 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +570 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +102 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +587 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +82 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +322 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Scoped permissions for operations that change things: editing/writing files, running shell commands, calling MCP tools.
3
+ *
4
+ * - Reads and searches inside the workspace need no permission (and nothing outside it is reachable at all).
5
+ * - Everything else asks, unless a rule allows exactly that. A rule names a tool and a precise scope:
6
+ * edit / write : a file, a directory subtree, or the whole workspace
7
+ * bash : one exact command line (never a prefix: `npm test` does not allow `npm test; rm -rf …`)
8
+ * mcp : one tool of one server, or all tools of one server
9
+ * - Session rules live in memory. Project rules are stored under OAKODE's own data directory (never inside the repository,
10
+ * so a cloned repo cannot ship permissions) and are only ever written after the user explicitly chooses "this project".
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { home } from "./config.js";
16
+ import { changedFiles, runnerFiles, runnerHashes } from "./runners.js";
17
+ const POLICIES = new Set(["allow", "ask", "deny"]);
18
+ /** Keys of the custom policy table: a built-in tool, `bash:<exact command>`, `mcp:*`, `mcp:<server>` or `mcp:<server>/<tool>`. */
19
+ const POLICY_KEY = /^(read|grep|ls|write|edit|bash|mcp:\*|mcp:[^\s/]{1,100}(\/\S{1,200})?|bash:[\s\S]{1,4000})$/;
20
+ export const DEFAULT_POLICY = { read: "allow", grep: "allow", ls: "allow", write: "ask", edit: "ask", bash: "ask", "mcp:*": "ask" };
21
+ const MAX_RULES = 500;
22
+ export function projectFile(cwd) {
23
+ return join(home(), "projects", createHash("sha256").update(cwd).digest("hex").slice(0, 16), "permissions.json");
24
+ }
25
+ /**
26
+ * Files whose contents later run code or instruct the agent, so a write there is never covered by a broad permission:
27
+ * git metadata (hooks and config run on the user's next git command), project instructions (AGENTS.md / CLAUDE.md are
28
+ * loaded into every request), project skills (.agents/) and the project's MCP server list (.mcp.json). Auto Edit,
29
+ * directory and workspace grants and Custom "Always Allow" do not apply to them; an exact per-file grant or a fresh
30
+ * approval does. Compared case-insensitively (macOS and Windows file systems usually are). Returns why, or undefined.
31
+ */
32
+ export function protectedPath(path) {
33
+ // Windows reaches the same file through other spellings: trailing dots and spaces are dropped (".git." is ".git"),
34
+ // ":stream" names an alternate data stream of the same file, and GIT~1 is .git's 8.3 short name.
35
+ const parts = path
36
+ .replace(/\\/g, "/")
37
+ .toLowerCase()
38
+ .split("/")
39
+ .map((p) => p.replace(/:.*$/, "").replace(/[. ]+$/, (m) => (p === m ? m : "")))
40
+ .map((p) => (/^git~\d+$/.test(p) ? ".git" : p))
41
+ .filter((p) => p && p !== ".");
42
+ if (parts.includes(".git"))
43
+ return "git metadata: hooks and config written here run on later git commands";
44
+ if (parts.includes(".husky"))
45
+ return "git hooks (husky): run on later git commands";
46
+ if (parts.includes(".agents"))
47
+ return "project skills: instructions the agent can load";
48
+ const base = parts.at(-1);
49
+ if (base === "agents.md" || base === "claude.md")
50
+ return "project instructions: loaded into every request";
51
+ if (base === ".mcp.json")
52
+ return "the project's MCP servers: programs OAKODE can start";
53
+ return undefined;
54
+ }
55
+ /** Strictly below `dir` (a directory rule never matches a file that happens to have the directory's own name). */
56
+ const inTree = (path, dir) => dir === "." || path.startsWith(dir + "/");
57
+ const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "") || ".";
58
+ /** The levels a user can pick for an action, narrowest first, with the default index. */
59
+ export function levelsFor(a) {
60
+ if (a.tool === "bash")
61
+ return [{ level: "command", value: a.command.trim(), label: `exactly this command` }];
62
+ if (a.tool === "mcp") {
63
+ return [
64
+ { level: "tool", value: `${a.server}/${a.mcpTool}`, label: `the tool ${a.mcpTool} of server ${a.server}` },
65
+ { level: "server", value: a.server, label: `every tool of server ${a.server}` },
66
+ ];
67
+ }
68
+ const p = norm(a.path);
69
+ const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : ".";
70
+ const verb = a.tool === "edit" ? "edits" : "writes";
71
+ if (protectedPath(p))
72
+ return [{ level: "file", value: p, label: `${verb} to ${p} (protected: only this exact file)` }];
73
+ return [
74
+ { level: "file", value: p, label: `${verb} to ${p}` },
75
+ { level: "directory", value: dir, label: `${verb} to files in ${dir === "." ? "the workspace root" : dir + "/"} and below` },
76
+ { level: "workspace", value: ".", label: `${verb} to any file in this workspace` },
77
+ ];
78
+ }
79
+ export function matches(r, a) {
80
+ if (r.tool !== a.tool)
81
+ return false;
82
+ // defense in depth: a path that is absolute or climbs out can never match any rule (tools already refuse such paths)
83
+ if (a.path !== undefined && (a.path.startsWith("/") || a.path.split(/[\\/]/).includes("..")))
84
+ return false;
85
+ switch (r.level) {
86
+ case "file": return norm(a.path ?? "") === r.value;
87
+ case "directory":
88
+ case "workspace": return !protectedPath(a.path ?? "") && inTree(norm(a.path ?? ""), r.value);
89
+ case "command": return typeof a.command === "string" && a.command.trim() === r.value;
90
+ case "tool": return `${a.server}/${a.mcpTool}` === r.value;
91
+ case "server": return a.server === r.value;
92
+ }
93
+ }
94
+ export const describe = (r) => {
95
+ switch (r.level) {
96
+ case "file": return `${r.tool}: the file ${r.value}`;
97
+ case "directory": return `${r.tool}: files in ${r.value === "." ? "the workspace root" : r.value + "/"} and below`;
98
+ case "workspace": return `${r.tool}: any file in this workspace`;
99
+ case "command": return `bash: exactly \`${r.value.length > 80 ? r.value.slice(0, 79) + "…" : r.value}\``;
100
+ case "tool": return `mcp: tool ${r.value}`;
101
+ case "server": return `mcp: every tool of server ${r.value}`;
102
+ }
103
+ };
104
+ const VALID_TOOLS = new Set(["edit", "write", "bash", "mcp"]);
105
+ const VALID_LEVELS = new Set(["file", "directory", "workspace", "command", "tool", "server"]);
106
+ /** Untrusted-input validation for the stored file: anything unexpected is dropped. */
107
+ function validRule(r) {
108
+ if (!r || typeof r !== "object")
109
+ return false;
110
+ const x = r;
111
+ if (typeof x.tool !== "string" || !VALID_TOOLS.has(x.tool) || typeof x.level !== "string" || !VALID_LEVELS.has(x.level))
112
+ return false;
113
+ if (typeof x.value !== "string" || x.value.length === 0 || x.value.length > 4000)
114
+ return false;
115
+ if (x.level === "file" || x.level === "directory" || x.level === "workspace") {
116
+ if (x.tool !== "edit" && x.tool !== "write")
117
+ return false;
118
+ if (x.value.startsWith("/") || x.value.split("/").includes(".."))
119
+ return false; // can never reach outside the workspace
120
+ }
121
+ if (x.level === "command" && x.tool !== "bash")
122
+ return false;
123
+ if ((x.level === "tool" || x.level === "server") && x.tool !== "mcp")
124
+ return false;
125
+ return true;
126
+ }
127
+ export class Permissions {
128
+ session = [];
129
+ project = [];
130
+ file;
131
+ seq = 0;
132
+ saved = {};
133
+ temp = {};
134
+ cwd;
135
+ /** Runner command → its command-defining files as they were when it was approved (see runners.ts). */
136
+ runners = Object.create(null);
137
+ constructor(cwd) {
138
+ if (cwd) {
139
+ this.cwd = cwd;
140
+ this.file = projectFile(cwd);
141
+ this.load();
142
+ // approvals saved before runner tracking: their baseline is the files as they are at this session's start
143
+ let adopted = false;
144
+ for (const c of this.allowedCommands())
145
+ if (!this.runners[c] && runnerFiles(c, this.cwd).length)
146
+ (this.runners[c] = this.hashes(c)), (adopted = true);
147
+ if (adopted)
148
+ this.save();
149
+ }
150
+ }
151
+ hashes(command) {
152
+ return runnerHashes(this.cwd, runnerFiles(command, this.cwd));
153
+ }
154
+ /** Exact command lines a rule or an Always Allow policy lets run (only the ones stored in the project file: `saved`). */
155
+ allowedCommands(saved = false) {
156
+ const bag = saved ? this.saved : { ...this.saved, ...this.temp };
157
+ const policies = Object.entries(bag).filter(([k, v]) => k.startsWith("bash:") && v === "allow").map(([k]) => k.slice(5).trim());
158
+ return [...new Set([...(saved ? this.project : this.all()).filter((r) => r.level === "command").map((r) => r.value), ...policies])];
159
+ }
160
+ /**
161
+ * For a runner command (npm test, make, ./run.sh …) that an approval covers: the command-defining files that changed
162
+ * since it was approved, so it must ask again. Undefined for other commands, or when nothing changed.
163
+ */
164
+ runnerChanged(command) {
165
+ const c = command.trim();
166
+ if (!this.cwd || !runnerFiles(c, this.cwd).length)
167
+ return undefined;
168
+ const base = this.runners[c];
169
+ if (!base)
170
+ return undefined;
171
+ const changed = changedFiles(base, this.hashes(c));
172
+ return changed.length ? changed : undefined;
173
+ }
174
+ /** The person approved this command just now, seeing why it asked: its files as they are now become the baseline. */
175
+ approvedNow(command) {
176
+ const c = command.trim();
177
+ if (!this.cwd || !runnerFiles(c, this.cwd).length || !this.allowedCommands().includes(c))
178
+ return;
179
+ this.runners[c] = this.hashes(c);
180
+ this.save();
181
+ }
182
+ load() {
183
+ try {
184
+ const j = JSON.parse(readFileSync(this.file, "utf8"));
185
+ this.saved = {};
186
+ if (j.policies && typeof j.policies === "object" && !Array.isArray(j.policies)) {
187
+ for (const [k, v] of Object.entries(j.policies).slice(0, MAX_RULES))
188
+ if (POLICY_KEY.test(k) && typeof v === "string" && POLICIES.has(v))
189
+ this.saved[k] = v;
190
+ }
191
+ this.project = (Array.isArray(j.rules) ? j.rules : []).filter(validRule).slice(0, MAX_RULES).map((r) => ({ ...r, scope: "project", id: `p${++this.seq}` }));
192
+ this.runners = Object.create(null);
193
+ const rs = j.runners;
194
+ if (rs && typeof rs === "object" && !Array.isArray(rs)) {
195
+ for (const [cmd, files] of Object.entries(rs).slice(0, MAX_RULES)) {
196
+ if (!POLICY_KEY.test(`bash:${cmd}`) || !files || typeof files !== "object" || Array.isArray(files))
197
+ continue;
198
+ const ok = Object.entries(files).slice(0, 500).filter(([f, h]) => typeof h === "string" && /^[0-9a-f]{0,64}$/.test(h) && f.length < 1000 && !f.startsWith("/") && !f.split("/").includes(".."));
199
+ this.runners[cmd] = Object.assign(Object.create(null), Object.fromEntries(ok));
200
+ }
201
+ }
202
+ }
203
+ catch {
204
+ this.project = [];
205
+ }
206
+ }
207
+ all() {
208
+ return [...this.session, ...this.project];
209
+ }
210
+ /** The rule that allows this action, if any. */
211
+ allowedBy(a) {
212
+ return this.all().find((r) => matches(r, a));
213
+ }
214
+ grant(a, level, scope) {
215
+ const opt = levelsFor(a).find((l) => l.level === level);
216
+ if (!opt)
217
+ throw new Error(`no such scope: ${level}`);
218
+ const existing = this.all().find((r) => r.tool === a.tool && r.level === level && r.value === opt.value && r.scope === scope);
219
+ if (existing)
220
+ return existing;
221
+ const rule = { id: `${scope[0]}${++this.seq}`, tool: a.tool, level, value: opt.value, scope, created: new Date().toISOString() };
222
+ if (level === "command" && this.cwd && runnerFiles(opt.value, this.cwd).length)
223
+ this.runners[opt.value] = this.hashes(opt.value);
224
+ if (scope === "session")
225
+ this.session.push(rule);
226
+ else {
227
+ if (this.project.length >= MAX_RULES)
228
+ throw new Error("too many project permissions; revoke some with /permissions");
229
+ this.project.push(rule);
230
+ this.save();
231
+ }
232
+ return rule;
233
+ }
234
+ /** Remove one rule by id (or all). Returns how many were removed. */
235
+ revoke(id) {
236
+ const before = this.all().length;
237
+ const keep = (r) => id !== "all" && r.id !== id;
238
+ const hadProject = this.project.some((r) => !keep(r));
239
+ this.session = this.session.filter(keep);
240
+ this.project = this.project.filter(keep);
241
+ if (hadProject)
242
+ this.save();
243
+ return before - this.all().length;
244
+ }
245
+ // ---- custom policy table (used by Custom mode) ----
246
+ /** The explicitly configured policy for a key (this session's change wins over the saved one). */
247
+ policyOf(key) {
248
+ if (Object.hasOwn(this.temp, key))
249
+ return { policy: this.temp[key], scope: "session" };
250
+ if (Object.hasOwn(this.saved, key))
251
+ return { policy: this.saved[key], scope: "project" };
252
+ return undefined;
253
+ }
254
+ /** Policy as shown in the table: configured, else the built-in default. */
255
+ shownPolicy(key) {
256
+ const p = this.policyOf(key);
257
+ return p ?? { policy: DEFAULT_POLICY[key] ?? "ask", scope: "default" };
258
+ }
259
+ /** Keys that have an explicit setting (for listing rows such as specific commands and MCP tools). */
260
+ policyKeys() {
261
+ return [...new Set([...Object.keys(this.saved), ...Object.keys(this.temp)])];
262
+ }
263
+ /** Set (or with `undefined` clear) a policy. "project" persists it outside the repository; "session" lasts until exit. */
264
+ setPolicy(key, policy, scope) {
265
+ if (!POLICY_KEY.test(key))
266
+ throw new Error(`invalid policy key: ${key}`);
267
+ const bag = scope === "session" ? this.temp : this.saved;
268
+ if (policy === undefined)
269
+ delete bag[key];
270
+ else {
271
+ if (!Object.hasOwn(bag, key) && Object.keys(bag).length >= MAX_RULES)
272
+ throw new Error("too many policy entries");
273
+ bag[key] = policy;
274
+ const cmd = key.startsWith("bash:") ? key.slice(5).trim() : "";
275
+ if (policy === "allow" && cmd && this.cwd && runnerFiles(cmd, this.cwd).length)
276
+ this.runners[cmd] = this.hashes(cmd);
277
+ }
278
+ if (scope === "project")
279
+ this.save();
280
+ }
281
+ get storagePath() {
282
+ return this.file;
283
+ }
284
+ save() {
285
+ if (!this.file)
286
+ return;
287
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
288
+ const tmp = this.file + `.${process.pid}.tmp`;
289
+ const keep = this.allowedCommands(true);
290
+ const runners = Object.fromEntries(Object.entries(this.runners).filter(([c]) => keep.includes(c)));
291
+ writeFileSync(tmp, JSON.stringify({ version: 1, rules: this.project.map(({ id: _id, scope: _s, ...r }) => r), policies: this.saved, runners }, null, 2), { mode: 0o600 });
292
+ chmodSync(tmp, 0o600);
293
+ renameSync(tmp, this.file);
294
+ }
295
+ }
296
+ export { existsSync };
297
+ /**
298
+ * Explicit, per-project trust decisions for things a repository can ship: its skills and its MCP server configuration.
299
+ * Stored next to the permissions (outside the repo). Nothing a repository contains can set these.
300
+ */
301
+ export class ProjectTrust {
302
+ file;
303
+ /**
304
+ * `skills`: the fingerprint each trusted project skill had when the user trusted it. Files written before fingerprints
305
+ * existed hold `skills: true`; their skills are adopted with their current contents the next time they load, and
306
+ * from then on a change disables them like any other.
307
+ */
308
+ data = { skills: {}, legacySkills: false, mcp: {} };
309
+ constructor(cwd) {
310
+ this.file = join(dirname(projectFile(cwd)), "trust.json");
311
+ const fp = (v, k) => typeof v === "string" && /^[0-9a-f]{16,64}$/.test(v) && k.length <= 100;
312
+ try {
313
+ const j = JSON.parse(readFileSync(this.file, "utf8"));
314
+ if (j.skills === true)
315
+ this.data.legacySkills = true;
316
+ else if (j.skills && typeof j.skills === "object" && !Array.isArray(j.skills)) {
317
+ for (const [k, v] of Object.entries(j.skills))
318
+ if (fp(v, k))
319
+ this.data.skills[k] = v;
320
+ }
321
+ if (j.mcp && typeof j.mcp === "object") {
322
+ for (const [k, v] of Object.entries(j.mcp))
323
+ if (fp(v, k))
324
+ this.data.mcp[k] = v;
325
+ }
326
+ }
327
+ catch { }
328
+ }
329
+ /** Whether any project skill is trusted. */
330
+ get skills() {
331
+ return this.data.legacySkills || Object.keys(this.data.skills).length > 0;
332
+ }
333
+ /** May this project skill, with this fingerprint, be active? */
334
+ skillStatus(name, fingerprint) {
335
+ const known = Object.hasOwn(this.data.skills, name) ? this.data.skills[name] : undefined;
336
+ if (known)
337
+ return known === fingerprint() ? true : "changed";
338
+ if (!this.data.legacySkills)
339
+ return false;
340
+ const fp = fingerprint();
341
+ if (!fp)
342
+ return false; // too large to verify
343
+ this.data.skills[name] = fp; // trusted before fingerprints: adopt what is there now
344
+ this.save();
345
+ return true;
346
+ }
347
+ /**
348
+ * Ends the adoption of skills trusted before fingerprints: called once the first load after starting has
349
+ * fingerprinted the skills that were there. A skill that appears later needs an explicit /skills trust.
350
+ */
351
+ endLegacyMigration() {
352
+ if (!this.data.legacySkills)
353
+ return;
354
+ this.data.legacySkills = false;
355
+ this.save();
356
+ }
357
+ /** Trust these skills with exactly these contents. */
358
+ trustSkills(skills) {
359
+ for (const s of skills)
360
+ if (s.fingerprint)
361
+ this.data.skills[s.name] = s.fingerprint;
362
+ this.save();
363
+ }
364
+ /** Disable every project skill again. */
365
+ untrustSkills() {
366
+ this.data.skills = {};
367
+ this.data.legacySkills = false;
368
+ this.save();
369
+ }
370
+ /** The config fingerprint the user approved for this server, if any. A changed config needs a new approval. */
371
+ mcp(name) {
372
+ return Object.hasOwn(this.data.mcp, name) ? this.data.mcp[name] : undefined;
373
+ }
374
+ setMcp(name, fingerprint) {
375
+ if (fingerprint)
376
+ this.data.mcp[name] = fingerprint;
377
+ else
378
+ delete this.data.mcp[name];
379
+ this.save();
380
+ }
381
+ save() {
382
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
383
+ const tmp = this.file + `.${process.pid}.tmp`;
384
+ const { legacySkills, skills, mcp } = this.data;
385
+ writeFileSync(tmp, JSON.stringify({ version: 1, skills: legacySkills && !Object.keys(skills).length ? true : skills, mcp }, null, 2), { mode: 0o600 });
386
+ chmodSync(tmp, 0o600);
387
+ renameSync(tmp, this.file);
388
+ }
389
+ }
@@ -0,0 +1,134 @@
1
+ import { MODE_LABEL, POLICY_LABEL, decide } from "./modes.js";
2
+ import { DEFAULT_POLICY, describe as describeRule } from "./permissions.js";
3
+ import { bold, dim, padEnd, yellow } from "./ui.js";
4
+ const ORDER = ["allow", "ask", "deny"];
5
+ const ICON = { allow: "✓", ask: "?", deny: "✗" };
6
+ /** Wildcard Always Allow for MCP would also approve tools a server adds later: not offered. */
7
+ const allowed = (key) => (key === "mcp:*" ? ["ask", "deny"] : ORDER);
8
+ function rows(app) {
9
+ const out = [
10
+ { kind: "policy", key: "read", name: "read" },
11
+ { kind: "policy", key: "grep", name: "grep" },
12
+ { kind: "policy", key: "ls", name: "ls" },
13
+ { kind: "policy", key: "write", name: "write (workspace)" },
14
+ { kind: "policy", key: "edit", name: "edit (workspace)" },
15
+ { kind: "policy", key: "bash", name: "bash (any command)", note: "unsandboxed" },
16
+ ];
17
+ const keys = app.permissions.policyKeys();
18
+ for (const k of keys.filter((k) => k.startsWith("bash:")).sort())
19
+ out.push({ kind: "policy", key: k, name: `bash: ${k.slice(5).replace(/\s+/g, " ").slice(0, 40)}` });
20
+ out.push({ kind: "policy", key: "mcp:*", name: "mcp:* (all MCP tools)" });
21
+ const seen = new Set();
22
+ for (const sv of app.mcp.servers()) {
23
+ seen.add(`mcp:${sv.def.name}`);
24
+ out.push({ kind: "policy", key: `mcp:${sv.def.name}`, name: `mcp:${sv.def.name} (server)` });
25
+ for (const t of sv.tools) {
26
+ seen.add(`mcp:${sv.def.name}/${t.name}`);
27
+ out.push({ kind: "policy", key: `mcp:${sv.def.name}/${t.name}`, name: ` ${t.name}` });
28
+ }
29
+ }
30
+ for (const k of keys.filter((k) => k.startsWith("mcp:") && k !== "mcp:*" && !seen.has(k)).sort())
31
+ out.push({ kind: "policy", key: k, name: k });
32
+ out.push({ kind: "add" });
33
+ for (const r of app.permissions.all())
34
+ out.push({ kind: "grant", id: r.id });
35
+ return out;
36
+ }
37
+ /** What the active mode does for this key right now (shown when the mode is not Custom). */
38
+ function now(app, key) {
39
+ if (app.mode === "custom")
40
+ return "";
41
+ const s = key.startsWith("mcp:") ? (() => { const [sv, t] = key.slice(4).split("/"); return { kind: "mcp", server: sv === "*" ? "" : sv, mcpTool: t ?? "" }; })() : key.startsWith("bash") ? { kind: "bash", command: key.slice(5) } : key === "write" || key === "edit" ? { kind: "file", tool: key, path: "x" } : { kind: "read", name: key };
42
+ const d = decide(app.mode, app.permissions, s);
43
+ return `now: ${ICON[d.decision]} ${d.decision}`;
44
+ }
45
+ export async function permissionsUi(app) {
46
+ const perms = app.permissions;
47
+ let target = "project";
48
+ let sel = 0;
49
+ const set = (key, p) => {
50
+ perms.setPolicy(key, p, target);
51
+ if (p === "allow" && key === "bash")
52
+ app.tui.flash("⚠ Always Allow for bash: any command runs without asking, unsandboxed, with your full permissions.", 5000);
53
+ else if (p === "allow" && (key === "write" || key === "edit"))
54
+ app.tui.flash(`${key} stays confined to the workspace; Always Allow only removes the prompt.`, 4000);
55
+ };
56
+ const line = (r) => {
57
+ if (r.kind === "add")
58
+ return { label: dim("+ Add a rule for a specific bash command…") };
59
+ if (r.kind === "grant") {
60
+ const g = perms.all().find((x) => x.id === r.id);
61
+ return { label: g ? `${padEnd(`granted ${g.id}`, 12)} ${describeRule(g)}` : r.id, hint: g ? (g.scope === "project" ? "saved · Enter: revoke" : "session only · Enter: revoke") : "" };
62
+ }
63
+ const p = perms.shownPolicy(r.key);
64
+ const tag = p.scope === "default" ? "default" : p.scope === "session" ? "session only" : "saved";
65
+ return { label: `${padEnd(r.name, 30)} ${padEnd(`${ICON[p.policy]} ${POLICY_LABEL[p.policy]}`, 17)}`, hint: [tag, now(app, r.key), r.note].filter(Boolean).join(" · ") };
66
+ };
67
+ for (;;) {
68
+ const list = rows(app);
69
+ sel = Math.min(sel, list.length - 1);
70
+ const i = await app.tui.chooseFrom({
71
+ title: `Permissions · ${MODE_LABEL[app.mode]}`,
72
+ lines: () => [
73
+ dim(`Editing the Custom policy (used when the mode is Custom). Changes apply to: `) + bold(target === "project" ? "saved for this project" : "this session only") + dim(" (Tab toggles)"),
74
+ ...(app.mode !== "custom" ? [dim(`The active mode is ${MODE_LABEL[app.mode]}; "now" shows what it does. Shift+Tab switches modes.`)] : []),
75
+ ...(app.autoApprove ? [yellow("--yes is ON: nothing asks, whatever is configured here (Deny and Plan still apply).")] : []),
76
+ dim("Tool / Operation".padEnd(31) + "Policy"),
77
+ ],
78
+ options: () => rows(app).map(line),
79
+ initial: sel,
80
+ onTab: () => void (target = target === "project" ? "session" : "project"),
81
+ hint: "↑↓ navigate · ←→ change · Tab saved/session · Enter details · Esc close",
82
+ onKey: (key, at) => {
83
+ if (key.k !== "left" && key.k !== "right")
84
+ return false;
85
+ sel = at;
86
+ const r = rows(app)[at];
87
+ if (r?.kind !== "policy")
88
+ return true;
89
+ const opts = allowed(r.key);
90
+ const cur = opts.indexOf(perms.shownPolicy(r.key).policy);
91
+ const next = opts[(Math.max(cur, 0) + (key.k === "right" ? 1 : opts.length - 1)) % opts.length];
92
+ set(r.key, next);
93
+ return true;
94
+ },
95
+ });
96
+ if (i < 0)
97
+ return;
98
+ sel = i;
99
+ const r = list[i];
100
+ if (r.kind === "grant") {
101
+ const j = await app.tui.chooseFrom({ title: `Revoke ${r.id}?`, options: () => [{ label: "Revoke this permission" }, { label: "Keep it" }] });
102
+ if (j === 0) {
103
+ perms.revoke(r.id);
104
+ app.view.note(`Revoked ${r.id}.`);
105
+ }
106
+ }
107
+ else if (r.kind === "add") {
108
+ app.view.note("Type the exact bash command this rule is for (Enter to continue, empty to cancel). It matches that exact command line only.");
109
+ const cmd = (await app.tui.readMessage({ prefill: "", multiline: false }))?.trim();
110
+ if (!cmd)
111
+ continue;
112
+ const j = await app.tui.chooseFrom({ title: `Policy for: ${cmd.slice(0, 70)}`, options: () => [...ORDER.map((p) => ({ label: POLICY_LABEL[p] })), { label: "Cancel" }] });
113
+ if (j >= 0 && j < 3)
114
+ set(`bash:${cmd}`, ORDER[j]);
115
+ }
116
+ else {
117
+ const explicit = perms.policyOf(r.key);
118
+ const opts = allowed(r.key);
119
+ const j = await app.tui.chooseFrom({
120
+ title: `${r.name.trim()} — ${target === "project" ? "saved for this project" : "this session only"}`,
121
+ options: () => [...opts.map((p) => ({ label: POLICY_LABEL[p], hint: p === perms.shownPolicy(r.key).policy ? "current" : DEFAULT_POLICY[r.key] === p ? "default" : "" })), ...(explicit ? [{ label: "Reset to default" }] : []), { label: "Cancel" }],
122
+ initial: Math.max(0, opts.indexOf(perms.shownPolicy(r.key).policy)),
123
+ });
124
+ if (j >= 0 && j < opts.length)
125
+ set(r.key, opts[j]);
126
+ else if (explicit && j === opts.length) {
127
+ perms.setPolicy(r.key, undefined, explicit.scope);
128
+ // a saved entry hidden behind a session one (or vice versa) is cleared too, so Reset really resets
129
+ if (perms.policyOf(r.key))
130
+ perms.setPolicy(r.key, undefined, explicit.scope === "session" ? "project" : "session");
131
+ }
132
+ }
133
+ }
134
+ }
package/dist/print.js ADDED
@@ -0,0 +1,76 @@
1
+ import { clip, cyan, dim, fmtTokens, green, red, yellow } from "./ui.js";
2
+ export function makePrintEmitter() {
3
+ let streaming = false;
4
+ let toolLines = 0;
5
+ const emit = (e) => {
6
+ switch (e.type) {
7
+ case "text":
8
+ if (!streaming)
9
+ (process.stdout.write("\n"), (streaming = true));
10
+ process.stdout.write(e.delta);
11
+ break;
12
+ case "tool_start":
13
+ if (streaming)
14
+ (process.stdout.write("\n"), (streaming = false));
15
+ toolLines = 0;
16
+ console.log(cyan(`\n▸ ${e.summary}`));
17
+ break;
18
+ case "tool_output":
19
+ for (const l of e.chunk.split("\n")) {
20
+ if (!l && toolLines >= 12)
21
+ continue;
22
+ if (toolLines < 12)
23
+ console.log(dim(" │ " + l.slice(0, 200)));
24
+ toolLines++;
25
+ }
26
+ break;
27
+ case "tool_end":
28
+ if (e.isError)
29
+ console.log(red(" ✗ " + clip(e.result, 4)));
30
+ else if (toolLines === 0)
31
+ console.log(dim(" " + clip(e.result, 6).replace(/\n/g, "\n ")));
32
+ else if (toolLines > 12)
33
+ console.log(dim(` … (+${toolLines - 12} more lines, full output sent to the model)`));
34
+ break;
35
+ case "usage":
36
+ case "reasoning":
37
+ break;
38
+ case "compacting":
39
+ console.log(dim("\n⟳ Compacting conversation (one model call)…"));
40
+ break;
41
+ case "compacted":
42
+ console.log(dim(`✓ Compacted: ~${fmtTokens(e.before)} → ~${fmtTokens(e.after)} tokens.`));
43
+ break;
44
+ case "notice":
45
+ console.log(yellow("\n" + e.text));
46
+ }
47
+ };
48
+ return { emit, end: () => streaming && process.stdout.write("\n") };
49
+ }
50
+ export { green };
51
+ /**
52
+ * All of stdin, decoded once at the end: decoding chunk by chunk would split multibyte characters at chunk boundaries.
53
+ * UTF-16 with a byte-order mark (Windows PowerShell pipes) is decoded as such; a UTF-8 BOM is dropped. If nothing has
54
+ * arrived after 3 s, says on stderr that it is waiting, so a pipe that never closes is not a silent hang.
55
+ */
56
+ export async function readAll(stream = process.stdin) {
57
+ const chunks = [];
58
+ const waiting = setTimeout(() => chunks.length || console.error(dim("Reading input from stdin… (end it with Ctrl+D, or pass the prompt as an argument)")), 3000);
59
+ try {
60
+ for await (const c of stream)
61
+ chunks.push(typeof c === "string" ? Buffer.from(c) : c);
62
+ }
63
+ finally {
64
+ clearTimeout(waiting);
65
+ }
66
+ return decodeInput(Buffer.concat(chunks));
67
+ }
68
+ export function decodeInput(b) {
69
+ if (b[0] === 0xff && b[1] === 0xfe)
70
+ return new TextDecoder("utf-16le").decode(b.subarray(2));
71
+ if (b[0] === 0xfe && b[1] === 0xff)
72
+ return new TextDecoder("utf-16be").decode(b.subarray(2));
73
+ if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf)
74
+ return b.subarray(3).toString("utf8");
75
+ return b.toString("utf8");
76
+ }
package/dist/proc.js ADDED
@@ -0,0 +1,63 @@
1
+ /** Child processes: their environment, starting a shell command, and stopping them (with everything they started). */
2
+ import { spawn } from "node:child_process";
3
+ import { shell } from "./shell.js";
4
+ /**
5
+ * Environment for processes OAKODE starts. Strips credentials this application holds so an approved command cannot
6
+ * read them from its environment. Other variables are inherited: this is NOT a sandbox.
7
+ */
8
+ export function childEnv(env = process.env) {
9
+ const out = {};
10
+ for (const [k, v] of Object.entries(env))
11
+ if (!/^(OICKI|OAKODE)_/i.test(k))
12
+ out[k] = v;
13
+ return out;
14
+ }
15
+ /**
16
+ * For the model's shell commands: no pager waiting for a keypress, no git credential prompt nobody can answer, and
17
+ * no forced color in output the model reads. (Not CI=1: that changes how test runners behave.)
18
+ */
19
+ export function commandEnv(env = process.env) {
20
+ const out = childEnv(env);
21
+ delete out.FORCE_COLOR;
22
+ return { ...out, NO_COLOR: "1", PAGER: "cat", GIT_PAGER: "cat", GH_PAGER: "cat", GIT_TERMINAL_PROMPT: "0" };
23
+ }
24
+ /** Runs a command line in the agent's shell, in its own process group (so it can be stopped with what it starts). */
25
+ export function spawnShell(command, cwd) {
26
+ const sh = shell();
27
+ const child = spawn(sh.bin, sh.args(command), {
28
+ cwd,
29
+ env: commandEnv(),
30
+ detached: process.platform !== "win32",
31
+ windowsHide: true,
32
+ windowsVerbatimArguments: sh.verbatim,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+ live.add(child);
36
+ return child;
37
+ }
38
+ /** Running children, so a signal to OAKODE can take them down with it. */
39
+ const live = new Set();
40
+ /** Register a long-lived child (an MCP server) so shutdown kills it with the rest. */
41
+ export const trackChild = (c) => void live.add(c);
42
+ export const untrackChild = (c) => void live.delete(c);
43
+ export function killAllChildren(sig = "SIGTERM") {
44
+ for (const c of live)
45
+ killTreeOf(c, sig);
46
+ }
47
+ /** Signals a child and everything it started. Windows has no process groups or SIGINT for them: always a kill. */
48
+ export function killTreeOf(c, sig) {
49
+ if (!c.pid)
50
+ return;
51
+ if (process.platform === "win32") {
52
+ // taskkill /T takes the whole tree (a shell and what it started).
53
+ try {
54
+ spawn("taskkill", ["/pid", String(c.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => { });
55
+ }
56
+ catch { }
57
+ return;
58
+ }
59
+ try {
60
+ process.kill(-c.pid, sig);
61
+ }
62
+ catch { }
63
+ }