symbiot 0.52.0 → 0.53.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.53.0
6
+
7
+ - **Agents work like Orca.** A Claude agent now just does the work, the way Claude does when you talk to it in Orca: no permission prompts, no "You" for a folder or a command. Symbiot's guard, which Claude Code runs before every action, still stops the few things only you do: pushing to main, force-pushing, publishing, deleting outside its folder, sudo, piping a script from the internet into a shell, reading your keys, changing Symbiot's settings. A stopped agent is told why and asks. Settings → Your agent → **Agents work like Orca** (on; off keeps allow lists).
8
+ - **One conversation that goes on.** When you answer an agent, the same conversation resumes, so it carries on knowing everything it knew, instead of a new agent rereading a brief.
9
+ - **Agents decide like a colleague.** They choose what can be undone themselves and say what they chose; they ask only about what can't be undone, costs money, goes out in your name or needs who you are, with their pick first.
10
+
11
+ ## 0.52.1
12
+
13
+ - **Agents stop asking you for permission inside your work.** An agent reaches your knowledge folders from the start, and an ops agent your repos too. When it needs more and proposes a list that stays inside the folders you gave Symbiot, asks for nothing that could run anything, and doesn't publish or reach another machine, Symbiot turns it on by itself and the agent carries on: no question reaches you. Only a list that reaches outside your work (`~/.ssh`, `~/.config`, another machine) still asks, and it says why. Asks already waiting clear the same way.
14
+
5
15
  ## 0.52.0
6
16
 
7
17
  - **Your agents, on the Tasks screen.** No need to open Agents: a line at the top says where everything stands ("1 question for you · 1 ready for your OK · 2 at work"), and a blob per project shows what its agent did or is doing, what it asks you (its answers as buttons, or your own words), Review and approve when its work is ready, and the extra tasks it suggests, each with **+ task** or **Skip**. The liquid moves left to make room.
package/README.md CHANGED
@@ -439,6 +439,27 @@ editor presets only open a tab and exit, so Symbiot can't see the agent you run
439
439
  in them. Send to repos stays disabled until its
440
440
  handoffs have started, so a double click can't send twice.
441
441
 
442
+ ### Agents work like Orca
443
+
444
+ Talk to Symbiot and its agents just do the work, the way Claude does in Orca: a Claude
445
+ run skips permission checks (`--dangerously-skip-permissions`), and when it asks you
446
+ something, your answer goes back into **the same conversation** (`--resume`), so the
447
+ agent carries on knowing everything it knew. Its brief tells it to decide what can be
448
+ undone itself (the approach, names, which of two fixes) and say what it chose, and to
449
+ ask only about what can't be undone, costs money, goes out in your name or needs who
450
+ you are, with options and its pick first.
451
+
452
+ What it still can't do is stopped by **Symbiot's guard** (`guard.mjs`), which Claude
453
+ Code runs before every action, whatever the agent decided: pushing to main or master,
454
+ force-pushing, publishing a package, deleting outside its folder (or `/`), sudo, piping
455
+ a script from the internet into a shell, reading your SSH keys or cloud credentials,
456
+ and changing Symbiot's own settings. A blocked agent is told why, and asks.
457
+
458
+ Settings → Your agent → **Agents work like Orca** is on by default. Off, a run keeps
459
+ its allow list, and an allow list it proposes is turned on by itself only when it stays
460
+ inside your work: the folders you gave Symbiot, not `~/.ssh` or `~/.config`, nothing
461
+ that could run anything, nothing that publishes or reaches another machine.
462
+
442
463
  ### Questions from your agent
443
464
 
444
465
  An agent working in your terminal or IDE stops to ask you things: which way to go,
package/agents.mjs CHANGED
@@ -3,13 +3,15 @@
3
3
  // tab, and the questions an unattended agent leaves for the user.
4
4
  import { spawn } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
- import { join, dirname } from "node:path";
6
+ import { join, dirname, resolve, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
9
9
  import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
11
  import { parseRun, lastRunText } from "./work.mjs";
12
12
  import { parseFacts } from "./handover.mjs";
13
+ import { scanRoots, repoPathMap } from "./scan.mjs";
14
+ import { knowledgeFolders } from "./knowledge.mjs";
13
15
 
14
16
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
15
17
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -139,7 +141,49 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
139
141
  // For Settings → Handoff: the connectors, whether this command's runs get them, and
140
142
  // the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
141
143
  function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
142
- const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
144
+ const fillHandoff = (tmpl, repoPath, prompt = HANDOFF_PROMPT) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(prompt));
145
+ // ---- working like Orca --------------------------------------------------------------
146
+ // In Orca you talk to Claude and it just does the work: permission checks skipped, one
147
+ // conversation that goes on. Symbiot's Claude runs work the same way (config.agentTrust:
148
+ // "full", the default; "ask" keeps the allow lists): --dangerously-skip-permissions, with
149
+ // guard.mjs as a PreToolUse hook that stops the few things only the owner does (push to
150
+ // main, publish, delete outside the folder, sudo, keys, Symbiot's settings), whatever the
151
+ // agent decides. And an answer goes back into the same conversation (--resume), so the
152
+ // agent that asked carries on knowing everything it knew.
153
+ const GUARD_HOOK = fileURLToPath(new URL("./guard.mjs", import.meta.url));
154
+ const GUARD_SETTINGS = join(CONFIG_DIR, "agent-guard.json");
155
+ const trustFull = (cfg = loadConfig()) => cfg.agentTrust !== "ask";
156
+ function guardSettings() {
157
+ const s = { hooks: { PreToolUse: [{ matcher: "Bash|Read|Edit|Write|MultiEdit|NotebookEdit|Grep|Glob", hooks: [{ type: "command", command: `node ${JSON.stringify(GUARD_HOOK)}` }] }] } };
158
+ try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(GUARD_SETTINGS, JSON.stringify(s, null, 2) + "\n", { mode: 0o600 }); } catch {}
159
+ return GUARD_SETTINGS;
160
+ }
161
+ function withTrust(tmpl) {
162
+ if (!isClaudeCmd(tmpl) || !trustFull()) return tmpl;
163
+ let c = String(tmpl).replace(/\s--permission-mode\s+\S+/g, "");
164
+ if (!/--dangerously-skip-permissions\b/.test(c)) c += " --dangerously-skip-permissions";
165
+ if (!/--settings\b/.test(c)) c += ` --settings ${JSON.stringify(guardSettings())}`;
166
+ return c;
167
+ }
168
+ const SESSION = "session.json", RESUME_MAX = 8, RESUME_AGE = 2 * 86400000;
169
+ const RESUME_PROMPT = "The user has answered: read the newest entries in .symbiot/ANSWERS.md and carry on with .symbiot/TASKS.md from where you were. Same rules as before.";
170
+ function sessionOf(path) { try { return JSON.parse(readSymbiot(path, SESSION)) || null; } catch { return null; } }
171
+ // Resume when an answer has come in since its last run, and the conversation is fresh enough
172
+ function resumeFor(path) {
173
+ const s = sessionOf(path); if (!s || !s.id || (s.runs || 0) >= RESUME_MAX || Date.now() - (s.at || 0) > RESUME_AGE) return null;
174
+ let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
175
+ return answered > (s.at || 0) ? s : null;
176
+ }
177
+ function noteSession(path, code, resumed) {
178
+ const f = join(path, ".symbiot", SESSION);
179
+ let run = {}; try { run = parseRun(lastRunText(readSymbiot(path, "agent.log"))); } catch {}
180
+ if (code !== 0) {
181
+ if (resumed && /No conversation found|session .*not found/i.test(readSymbiot(path, "agent.log").slice(-2000))) { try { unlinkSync(f); } catch {} return "lost"; }
182
+ return null;
183
+ }
184
+ if (run.session) { const prev = sessionOf(path) || {}; try { writeFileSync(f, JSON.stringify({ id: run.session, at: Date.now(), runs: prev.id === run.session ? (prev.runs || 1) + 1 : 1 })); } catch {} }
185
+ return run.session || null;
186
+ }
143
187
  // One agent per folder: two identical runs once started on the same repo 6s
144
188
  // apart and raced each other. The registry catches a second click in this
145
189
  // process; .symbiot/agent.pid catches another one (`symbiot push --open` while
@@ -159,15 +203,19 @@ function runHandoff(repoPath, { force = false } = {}) {
159
203
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
160
204
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
161
205
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
162
- tmpl = withStream(withConnectors(tmpl, repoPath));
206
+ tmpl = withStream(withTrust(withScope(withConnectors(tmpl, repoPath), repoPath)));
207
+ const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
208
+ const runCmd = resume ? tmpl + ` --resume ${resume.id}` : tmpl;
163
209
  if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
164
210
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
165
211
  try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
166
212
  const lock = join(repoPath, ".symbiot", LOCK);
167
213
  const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
168
- const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
214
+ const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
215
+ if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
169
216
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
170
217
  noteBlocked(repoPath, tmpl, e.startedAt, code);
218
+ setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
171
219
  startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
172
220
  }, env);
173
221
  if (!e) return null;
@@ -673,6 +721,75 @@ function installAllowlist(path) {
673
721
  try { mkdirSync(join(path, ".claude"), { recursive: true }); writeFileSync(f, JSON.stringify(cur, null, 2) + "\n", { mode: 0o600 }); } catch { return null; }
674
722
  return [`the allow list for this folder (${allow.length} rule${allow.length === 1 ? "" : "s"})`];
675
723
  }
724
+ // ---- the owner's work, and allow lists inside it ---------------------------------------
725
+ // What the owner told Symbiot is their work: the folders it scans (Settings → Folders) and
726
+ // their knowledge folders, but not a hidden folder at the top of their home (~/.ssh,
727
+ // ~/.config: keys and settings), bar the folders Symbiot's own agents run in. An agent
728
+ // reaches its knowledge folders from the start (withScope), and an allow list it proposes
729
+ // that stays inside this work, asks for nothing wide, and doesn't publish or reach another
730
+ // machine is turned on by itself when its run ends (autoAllow): no question for the user.
731
+ const PUBLISH_RULE = /^Bash\(\s*(git push|git\s+-C\s+\S+\s+push|npm publish|yarn publish|pnpm publish|aws|gcloud|az|kubectl|docker|podman|terraform|ssh|scp|sftp|rsync|ftp|heroku|vercel|netlify|fly|flyctl)\b/i;
732
+ function workRoots() {
733
+ const roots = new Set();
734
+ try { for (const r of scanRoots()) roots.add(r); } catch {}
735
+ try { for (const k of knowledgeFolders()) roots.add(k.path); } catch {}
736
+ return [...roots].filter(Boolean).map((r) => r.replace(/\/+$/, "") || "/");
737
+ }
738
+ function inWork(p, roots = workRoots()) {
739
+ if (!p) return false;
740
+ p = resolve(String(p).replace(/^~(?=\/|$)/, homedir()).replace(/[/*]+$/, "")) || "/";
741
+ const drafts = join(CONFIG_DIR, "drafts");
742
+ if (p === drafts || p.startsWith(drafts + "/")) return true;
743
+ if (!roots.some((r) => p === r || p.startsWith(r === "/" ? "/" : r + "/"))) return false;
744
+ const rel = relative(homedir(), p);
745
+ if (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) return false; // ~/.ssh, ~/.config…
746
+ return true;
747
+ }
748
+ // The paths a rule names: Read(/x/**), Bash(find /x:*), additionalDirectories' /x.
749
+ const rulePaths = (r) => (String(r).match(/(?:~|\/)[^\s:*()"',]*/g) || []).filter((x) => x === "~" || x.length > 1);
750
+ // Whether a proposed list can be turned on without asking: { ok } or { why }.
751
+ function allowlistInWork(path) {
752
+ let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return { why: "no list" }; }
753
+ const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
754
+ const allow = list(p.allow), dirs = list(p.additionalDirectories), roots = [...workRoots(), path];
755
+ const wide = allow.filter(wideRule); if (wide.length) return { why: "wide: " + wide.join(", ") };
756
+ const pub = allow.filter((r) => PUBLISH_RULE.test(r)); if (pub.length) return { why: "publishes or reaches another machine: " + pub.join(", ") };
757
+ const out = [...dirs, ...allow.flatMap(rulePaths)].filter((x) => !inWork(x, roots));
758
+ if (out.length) return { why: "outside your work: " + [...new Set(out)].slice(0, 3).join(", ") };
759
+ return { ok: true, allow, dirs };
760
+ }
761
+ // A run ended asking for its proposed list: inside the work, it's turned on, the
762
+ // question is answered for the user, and the agent starts again. Once per proposal
763
+ // (.symbiot/autoallow.json), so an agent can't loop on it; past that it's the user's.
764
+ function autoAllow(path) {
765
+ if (!path || runningHandoff(path)) return null;
766
+ const f = join(path, ".symbiot", "allowlist.proposed.json"); let raw = ""; try { raw = readFileSync(f, "utf8"); } catch { return null; }
767
+ const q = agentQuestions(path, "").questions.find((x) => (x.options || []).some((o) => /allowlist\.proposed\.json/i.test(o)) || /allowlist\.proposed\.json/i.test(x.context || ""));
768
+ if (!q) return null;
769
+ const mark = join(path, ".symbiot", "autoallow.json"), d = digest(raw); let done = []; try { done = JSON.parse(readFileSync(mark, "utf8")); } catch {}
770
+ if (done.includes(d)) return null;
771
+ const v = allowlistInWork(path); if (!v.ok) return { asked: true, why: v.why };
772
+ const g = installAllowlist(path); if (!g || g.refused) return null;
773
+ try { writeFileSync(mark, JSON.stringify([...done, d].slice(-20))); } catch {}
774
+ const day = new Date().toISOString().slice(0, 10);
775
+ try { writeFileSync(join(path, ".symbiot", "ANSWERS.md"), (readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n").replace(/\s*$/, "\n") + `\n### ${q.q}\nSymbiot turned your list on (it stays inside the user's work and asks for nothing wide): .claude/settings.local.json in this folder. Carry on.\n_answered ${day}_\n`); } catch {}
776
+ try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {}
777
+ const e = runHandoff(path, { force: true });
778
+ return { allowed: true, rerun: e && e.id ? e.id : null };
779
+ }
780
+ // Every run that stopped asking for its list, now: on start and every so often, so
781
+ // asks from before this version clear too.
782
+ function autoAllowSweep() { const out = []; for (const r of loadRuns()) { try { const a = autoAllow(r.path); if (a && a.allowed) out.push(r.path); } catch {} } return out; }
783
+ // Every agent reaches the owner's knowledge folders from the start; an ops run (no repo
784
+ // of its own) their repos too, the way a person at their desk would.
785
+ function withScope(tmpl, repoPath) {
786
+ if (!isClaudeCmd(tmpl)) return tmpl;
787
+ const dirs = new Set(); try { for (const k of knowledgeFolders()) dirs.add(k.path); } catch {}
788
+ if (/\/drafts\/act-[^/]+$/.test(String(repoPath || ""))) { try { for (const p of Object.values(repoPathMap() || {})) dirs.add(p); } catch {} }
789
+ dirs.delete(repoPath);
790
+ let cmd = tmpl; for (const d of dirs) if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`;
791
+ return cmd;
792
+ }
676
793
  function grantFromStep(step, path = "", notes = {}) {
677
794
  const t = String(step || "");
678
795
  if (path && /allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(t)) { const g = installAllowlist(path); if (g && g.refused) { notes.refused = g.refused; return null; } if (g) return g; }
@@ -771,4 +888,4 @@ function agentsList() {
771
888
  }).concat(earlierRuns());
772
889
  }
773
890
 
774
- export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
891
+ export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
package/guard.mjs ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // The guard: what an agent working "like Orca" (permission checks skipped, so it just
3
+ // does the work) still can't do. Claude Code runs this before every tool call (a
4
+ // PreToolUse hook, from the settings Symbiot passes the run), and a call it blocks
5
+ // never happens, whatever the agent was told or decided. The list is short on purpose:
6
+ // the things only the owner should do, or that can't be undone.
7
+ // - push to main/master, or force-push; publish a package
8
+ // - delete outside the agent's own folder, or wipe a disk
9
+ // - sudo, or pipe something from the internet into a shell
10
+ // - read SSH keys or cloud credentials; change Symbiot's own settings
11
+ // judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
12
+ import { resolve, join, relative } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { execFileSync } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
19
+ const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
20
+ const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
21
+ function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
22
+ function symbiotConfig(p, home) { return p === join(home, ".config", "symbiot", "config.json"); }
23
+
24
+ // the branch a bare `git push` pushes: the one checked out in cwd
25
+ function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
26
+
27
+ function judgeBash(cmd, { cwd, home, branch }) {
28
+ const c = String(cmd || "");
29
+ // each simple command on its own (a; b && c | d)
30
+ for (const part of c.split(/&&|\|\||;|\n/)) {
31
+ const s = part.trim(); if (!s) continue;
32
+ const w = s.split(/\s+/);
33
+ if (/^(sudo|su|doas)$/.test(w[0])) return { why: "sudo: only you run things as root" };
34
+ if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
35
+ if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(w[0]) || (/^dd$/.test(w[0]) && /\bof=\/dev\//.test(s))) return { why: "that writes a disk directly" };
36
+ if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
37
+ const args = w.slice(1).filter((x, i, a) => !(a[i - 1] === "-C" || x === "-C"));
38
+ if (args[0] === "push") {
39
+ if (args.some((a) => /^(-f|--force|--force-with-lease)(=|$)/.test(a) || /^\+/.test(a))) return { why: "force-pushing rewrites history others may have" };
40
+ const refs = args.slice(1).filter((a) => !a.startsWith("-"));
41
+ const target = refs.length >= 2 ? refs[refs.length - 1].split(":").pop() : branch;
42
+ if (/^(main|master)$/.test(target || "")) return { why: "pushing to " + target + ": changes go on a branch and a PR (Approve), never straight to " + target };
43
+ }
44
+ }
45
+ if (/^(npm|yarn|pnpm|bun)$/.test(w[0]) && w[1] === "publish") return { why: "publishing a package is yours to do (Approve ships it)" };
46
+ if (/^(twine|cargo|gem|poetry)$/.test(w[0]) && /\b(upload|publish|push)\b/.test(s)) return { why: "publishing a package is yours to do" };
47
+ if (/^rm$/.test(w[0])) {
48
+ const paths = w.slice(1).filter((a) => !a.startsWith("-"));
49
+ for (const p of paths) {
50
+ const abs = expand(p, home, cwd);
51
+ if (abs === "/" || abs === home || /^\/(\*|bin|boot|dev|etc|lib|lib64|proc|root|sbin|sys|usr|var)(\/|$)/.test(abs)) return { why: "deleting " + p + " can't be undone" };
52
+ if (!under(abs, cwd) && !under(abs, "/tmp") && !under(abs, join(home, ".cache"))) return { why: "deleting outside this folder (" + p + "): ask for it" };
53
+ }
54
+ }
55
+ if (/^(cat|less|more|head|tail|cp|scp|base64|xxd|strings|grep|rg|sed|awk)$/.test(w[0])) {
56
+ for (const p of w.slice(1).filter((a) => !a.startsWith("-"))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
57
+ }
58
+ if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/config\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
59
+ }
60
+ if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c)) return { why: "running a script straight from the internet" };
61
+ return null;
62
+ }
63
+
64
+ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch } = {}) {
65
+ const t = String(tool || "");
66
+ if (t === "Bash") return judgeBash(input.command, { cwd, home, branch: branch != null ? branch : currentBranch(cwd) });
67
+ const p = input.file_path || input.path || input.notebook_path;
68
+ if (!p) return null;
69
+ const abs = expand(p, home, cwd);
70
+ if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
71
+ if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
72
+ return null;
73
+ }
74
+
75
+ // As a hook: Claude Code passes the call as JSON on stdin; exit 2 blocks it, and what's
76
+ // on stderr goes back to the agent as the reason (so it can ask, or do it another way).
77
+ const main = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
78
+ if (main) {
79
+ let raw = ""; try { raw = readFileSync(0, "utf8"); } catch {}
80
+ let ev = {}; try { ev = JSON.parse(raw); } catch {}
81
+ const r = judge(ev.tool_name, ev.tool_input || {}, { cwd: ev.cwd || process.cwd() });
82
+ if (r) { process.stderr.write("Blocked by Symbiot's guard: " + r.why + ". If it's needed, ask the user in .symbiot/QUESTIONS.md with options.\n"); process.exit(2); }
83
+ process.exit(0);
84
+ }
85
+
86
+ export { judge, judgeBash, relative };
package/handover.mjs CHANGED
@@ -24,7 +24,7 @@ function handoverRules(lanes = [], here = "") {
24
24
  }
25
25
  // The rule for options in QUESTIONS.md: the user gets only what no agent can do,
26
26
  // and the option says which kind it is.
27
- const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user: write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`) and offer `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`. Picking it in Symbiot turns it on for this folder and runs you again; don't ask them to copy files or run claude in a terminal for it.";
27
+ const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's guard stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it.";
28
28
 
29
29
  // What a run hands back to Symbiot itself (handback.mjs reads it): facts for
30
30
  // memory, and emails that wait on a reply, which Symbiot then watches for; and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.52.0",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.53.0",
4
+ "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "symbiot": "index.mjs"
@@ -41,7 +41,8 @@
41
41
  "CHANGELOG.md",
42
42
  "LICENSE",
43
43
  "fonts",
44
- "mapknn.mjs"
44
+ "mapknn.mjs",
45
+ "guard.mjs"
45
46
  ],
46
47
  "engines": {
47
48
  "node": ">=18"
@@ -74,7 +75,7 @@
74
75
  "@anthropic-ai/sdk": "^0.128.0"
75
76
  },
76
77
  "scripts": {
77
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
78
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
78
79
  "lint": "eslint ."
79
80
  },
80
81
  "devDependencies": {
package/server.mjs CHANGED
@@ -12,7 +12,7 @@ import { createServer } from "node:http";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { EMBEDDED_UI } from "./ui.mjs";
14
14
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
15
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths } from "./agents.mjs";
15
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
16
16
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
17
17
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
18
18
  import { computeDrift } from "./drift.mjs";
@@ -245,6 +245,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
245
245
  if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
246
246
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
247
247
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
248
+ if (u.pathname === "/api/agent/trust") return json(res, { full: trustFull() });
249
+ if (u.pathname === "/api/agent/trust/set" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); if (b.full) delete cfg.agentTrust; else cfg.agentTrust = "ask"; saveConfig(cfg); return json(res, { full: trustFull() }); }
248
250
  if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
249
251
  if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || ""), { force: !!b.force }); return json(res, { opened: !!e && !e.busy && !e.blocked, busy: !!(e && e.busy), auto: !!(e && e.auto), blocked: !!(e && e.blocked), note: (e && e.note) || "", id: (e && e.id) || "" }); }
250
252
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
@@ -424,6 +426,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
424
426
  startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
425
427
  setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
426
428
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
429
+ // an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
430
+ setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
431
+ setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();
427
432
  // knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
428
433
  const knowTick = () => { try { const r = knowledgeTick(); if (r && (r.read || r.removed || !checksState().at)) runChecks(); } catch {} };
429
434
  setTimeout(knowTick, 5000).unref(); setInterval(knowTick, 3 * 60 * 1000).unref();
package/ui.mjs CHANGED
@@ -929,6 +929,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
929
929
  <h3 class="ssh">Hand off to your agent when you "Send to repos"</h3>
930
930
  <input id="agentcmd" type="text" placeholder="e.g. aider --message &quot;{prompt}&quot; --yes · code {dir} · leave blank to just write the file">
931
931
  <div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor &mdash; it's your command.</div>
932
+ <label class="check swl" style="margin-top:12px"><input type="checkbox" class="sw" id="agenttrust" checked> <span>Agents work like Orca: they just do it. Symbiot's guard still stops pushing to main, publishing, deleting outside their folder, sudo, your keys and Symbiot's settings. Off: they ask before anything outside their allow list.</span></label>
932
933
  <div id="agentpresets" style="margin-top:8px"></div>
933
934
  <div class="note muted" id="agentconnectors"></div>
934
935
  <div id="grantbox" style="margin-top:10px">
@@ -1951,6 +1952,7 @@ el.querySelectorAll('.bdraft').forEach(function(btn){btn.addEventListener('click
1951
1952
  api('/api/watch/draft',{id:n.id}).then(function(x){btn.disabled=false;if(!x||x.error){boardMsg('err',esc((x&&x.error)||'failed'));return;}boardMsg('ok',x.chat?DRAFTING_CHAT:x.social?DRAFTING_SOCIAL:DRAFTING);loadWatchUI();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});}
1952
1953
  wire(el);wire($('boardtalk'));wire($('bsov'));BWIRE=wire;}
1953
1954
  $('boardrefresh').addEventListener('click',loadBoard);
1955
+ var atr=$('agenttrust');if(atr&&atr.addEventListener){api('/api/agent/trust').then(function(r){if(r)atr.checked=!!r.full;}).catch(function(){});atr.addEventListener('change',function(){api('/api/agent/trust/set',{full:atr.checked}).then(function(r){if(r)atr.checked=!!r.full;});});}
1954
1956
  var bw=$('boardwin');if(bw&&bw.querySelectorAll)bw.querySelectorAll('button').forEach(function(b){b.addEventListener('click',function(){BOARDH=+b.getAttribute('data-h')||24;bw.querySelectorAll('button').forEach(function(x){x.setAttribute('aria-checked',x===b?'true':'false');});loadBoard();});});
1955
1957
  // A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
1956
1958
  // no dragging. It takes a few seconds, so the button says so meanwhile.
package/work.mjs CHANGED
@@ -53,6 +53,7 @@ function parseRun(text) {
53
53
  const byId = new Map(), tasks = new Map(); let thinking = false;
54
54
  for (const e of events) {
55
55
  const at = e.timestamp ? Date.parse(e.timestamp) : null;
56
+ if (e.session_id && !out.session) out.session = String(e.session_id);
56
57
  if (e.type === "system" && e.subtype === "init") { out.model = String(e.model || ""); continue; }
57
58
  if (e.type === "assistant" && e.message && Array.isArray(e.message.content)) {
58
59
  for (const b of e.message.content) {